Files

1067 lines
27 KiB
Go

package interpreter
import (
"context"
"maps"
"math/big"
"regexp"
"slices"
"strings"
"github.com/hanzo-fi/numscript/internal/analysis"
"github.com/hanzo-fi/numscript/internal/flags"
"github.com/hanzo-fi/numscript/internal/parser"
"github.com/hanzo-fi/numscript/internal/utils"
)
type VariablesMap map[string]string
type InterpreterError interface {
error
parser.Ranged
}
type Metadata = map[string]Value
type Posting struct {
Source string `json:"source"`
SourceScope string `json:"sourceScope,omitempty"`
Destination string `json:"destination"`
DestinationScope string `json:"destinationScope,omitempty"`
Amount *big.Int `json:"amount"`
Asset string `json:"asset"`
Color string `json:"color,omitempty"`
}
// newPosting builds a Posting from the source and destination addresses,
// exposing each address's account and scope as the separate fields the posting
// contract uses.
func newPosting(source AccountAddress, destination AccountAddress, amount *big.Int, asset string, color string) Posting {
return Posting{
Source: source.Name,
SourceScope: source.Scope,
Destination: destination.Name,
DestinationScope: destination.Scope,
Amount: amount,
Asset: asset,
Color: color,
}
}
type ExecutionResult struct {
Postings []Posting `json:"postings"`
Metadata Metadata `json:"txMeta"`
AccountsMetadata SetAccountsMetadata `json:"accountsMeta"`
}
func parseMonetary(source string) (Monetary, InterpreterError) {
parts := strings.Split(source, " ")
if len(parts) != 2 {
return Monetary{}, InvalidMonetaryLiteral{Source: source}
}
asset := parts[0]
rawAmount := parts[1]
n, ok := new(big.Int).SetString(rawAmount, 10)
if !ok {
return Monetary{}, InvalidNumberLiteral{Source: rawAmount}
}
parsedAsset, err := NewAsset(asset)
if err != nil {
return Monetary{}, err
}
mon := Monetary{
Asset: parsedAsset,
Amount: MonetaryInt(*n),
}
return mon, nil
}
func parseVar(type_ string, rawValue string, r parser.Range) (Value, InterpreterError) {
switch type_ {
// TODO why should the runtime depend on the static analysis module?
case analysis.TypeMonetary:
return parseMonetary(rawValue)
case analysis.TypeAccount:
return NewAccountAddress(rawValue)
case analysis.TypePortion:
bi, err := ParsePortionSpecific(rawValue)
if err != nil {
return nil, err
}
return Portion(*bi), nil
case analysis.TypeAsset:
return NewAsset(rawValue)
case analysis.TypeNumber:
n, ok := new(big.Int).SetString(rawValue, 10)
if !ok {
return nil, InvalidNumberLiteral{Source: rawValue}
}
return MonetaryInt(*n), nil
case analysis.TypeString:
return String(rawValue), nil
default:
return nil, InvalidTypeErr{Name: type_, Range: r}
}
}
func evaluateVarOrigin(env *evalEnv, type_ string, expr parser.ValueExpr) (Value, InterpreterError) {
if fnCall, ok := expr.(*parser.FnCall); ok {
return evaluateFnCall(env, &type_, *fnCall)
}
return evaluateExpr(env, expr)
}
const accountSegmentRegex = "[a-zA-Z0-9_-]+"
var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$")
// https://github.com/formancehq/ledger/blob/main/pkg/accounts/accounts.go
func checkAccountName(addr string) bool {
return accountNameRegex.Match([]byte(addr))
}
var assetNameRegexp = regexp.MustCompile(`^[A-Z][A-Z0-9]{0,16}(_[A-Z]{1,16})?(\/\d{1,6})?$`)
// https://github.com/formancehq/ledger/blob/main/pkg/assets/asset.go
func checkAssetName(v string) bool {
return assetNameRegexp.Match([]byte(v))
}
var scopeRegex = regexp.MustCompile(`^[a-z0-9_]*$`)
func checkScopeName(scope string) bool {
return scopeRegex.MatchString(scope)
}
// Check the following invariants:
// - no negative postings
// - no invalid account names
// - no invalid asset names
func checkPostingInvariants(posting Posting) InterpreterError {
isAmtNegative := posting.Amount.Cmp(big.NewInt(0)) == -1
isInvalidPosting := (isAmtNegative ||
!checkAssetName(posting.Asset) ||
!checkAccountName(posting.Source) ||
!checkAccountName(posting.Destination))
if isInvalidPosting {
return InternalError{Posting: posting}
}
return nil
}
func RunProgram(
ctx context.Context,
program parser.Program,
vars map[string]string,
store Store,
featureFlags map[string]struct{},
) (*ExecutionResult, InterpreterError) {
flagSet := maps.Clone(featureFlags)
if flagSet == nil {
flagSet = make(map[string]struct{}, len(program.Flags))
}
for _, flag := range program.Flags {
index := slices.Index(flags.AllFlags, flag.String)
if index == -1 {
return nil, InvalidFeature{
Feature: flag.String,
}
}
flagSet[flag.String] = struct{}{}
}
env, err := newEvalEnv(ctx, store, flagSet, program.Vars, vars)
if err != nil {
return nil, err
}
st := programState{
evalEnv: env,
TxMeta: make(map[string]Value),
SetAccountsMeta: internalSetAccountsMeta{},
Postings: make([]Posting, 0),
fundsQueue: newFundsQueue(nil),
CurrentBalanceQuery: BalanceQuery{},
}
// preload balances before executing the script
for _, statement := range program.Statements {
err := st.findBalancesQueriesInStatement(statement)
if err != nil {
return nil, err
}
}
genericErr := st.runBalancesQuery()
if genericErr != nil {
return nil, QueryBalanceError{WrappedError: genericErr}
}
for _, statement := range program.Statements {
err := st.runStatement(statement)
if err != nil {
return nil, err
}
}
for _, posting := range st.Postings {
err := checkPostingInvariants(posting)
if err != nil {
return nil, err
}
}
res := &ExecutionResult{
Postings: st.Postings,
Metadata: st.TxMeta,
AccountsMetadata: st.SetAccountsMeta.toRows(),
}
return res, nil
}
type programState struct {
evalEnv
// Asset of the send statement currently being executed.
//
// its value is undefined outside of send statements execution
CurrentAsset Asset
TxMeta map[string]Value
Postings []Posting
fundsQueue fundsQueue
SetAccountsMeta internalSetAccountsMeta
CurrentBalanceQuery BalanceQuery
}
func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, color String) {
monetaryBi := big.Int(monetary)
if monetaryBi.Cmp(big.NewInt(0)) == 0 {
return
}
balance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, color)
balance.Sub(balance, &monetaryBi)
st.fundsQueue.Push(Sender{
Account: name,
Amount: &monetaryBi,
Color: string(color),
})
}
// Append a posting without checking if account has enough balance.
// Updates both source and destination balances.
// Noop if the amount is zero
func (st *programState) forcePushPostingUncolored(
source AccountAddress,
destination AccountAddress,
amount MonetaryInt,
asset Asset,
) {
amtBi := big.Int(amount)
if amtBi.Sign() == 0 {
return
}
srcBalance := st.CachedBalances.fetchBalance(source, asset, "")
srcBalance.Sub(srcBalance, &amtBi)
destBalance := st.CachedBalances.fetchBalance(destination, asset, "")
destBalance.Add(destBalance, &amtBi)
st.Postings = append(st.Postings, newPosting(source, destination, new(big.Int).Set(&amtBi), string(asset), ""))
}
func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) {
if monetary.Cmp(big.NewInt(0)) == 0 {
return
}
senders := st.fundsQueue.PullAnything(monetary)
for _, sender := range senders {
posting := newPosting(sender.Account, name, sender.Amount, string(st.CurrentAsset), sender.Color)
if name.Name == KEPT_ADDR {
// If funds are kept, give them back to senders
srcBalance := st.CachedBalances.fetchBalance(sender.Account, st.CurrentAsset, String(sender.Color))
srcBalance.Add(srcBalance, posting.Amount)
continue
}
destBalance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, String(sender.Color))
destBalance.Add(destBalance, posting.Amount)
st.Postings = append(st.Postings, posting)
}
}
func (st *programState) runStatement(statement parser.Statement) InterpreterError {
switch statement := statement.(type) {
case *parser.FnCall:
args, err := evaluateExpressions(&st.evalEnv, statement.Args)
if err != nil {
return err
}
switch statement.Caller.Name {
case analysis.FnSetTxMeta:
return setTxMeta(st, statement.Caller.Range, args)
case analysis.FnSetAccountMeta:
return setAccountMeta(st, statement.Caller.Range, args)
default:
return UnboundFunctionErr{Name: statement.Caller.Name}
}
case *parser.SendStatement:
return st.runSendStatement(*statement)
case *parser.SaveStatement:
return st.runSaveStatement(*statement)
default:
utils.NonExhaustiveMatchPanic[any](statement)
return nil
}
}
func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) InterpreterError {
asset, amt, err := evaluateSentAmt(&st.evalEnv, saveStatement.SentValue)
if err != nil {
return err
}
account, err := evaluateExprAs(&st.evalEnv, saveStatement.Account, expectAccount)
if err != nil {
return err
}
balance := st.CachedBalances.fetchBalance(account, asset, "")
if amt == nil {
if balance.Sign() > 0 {
balance.Set(big.NewInt(0))
}
} else {
// Do not allow negative saves
if amt.Cmp(big.NewInt(0)) == -1 {
return NegativeAmountErr{
Range: saveStatement.SentValue.GetRange(),
Amount: MonetaryInt(*amt),
}
}
// we decrease the balance by "amt"
balance.Sub(balance, amt)
// without going under 0
if balance.Cmp(big.NewInt(0)) == -1 {
balance.Set(big.NewInt(0))
}
}
return nil
}
func (st *programState) runSendStatement(statement parser.SendStatement) InterpreterError {
switch sentValue := statement.SentValue.(type) {
case *parser.SentValueAll:
asset, err := evaluateExprAs(&st.evalEnv, sentValue.Asset, expectAsset)
if err != nil {
return err
}
st.CurrentAsset = asset
sentAmt, err := st.takeAll(statement.Source)
if err != nil {
return err
}
return st.sendTo(statement.Destination, sentAmt)
case *parser.SentValueLiteral:
monetary, err := evaluateExprAs(&st.evalEnv, sentValue.Monetary, expectMonetary)
if err != nil {
return err
}
st.CurrentAsset = monetary.Asset
amtBi := big.Int(monetary.Amount)
if amtBi.Sign() == -1 {
return NegativeAmountErr{Amount: monetary.Amount, Range: sentValue.Monetary.GetRange()}
}
err = st.tryTakingExact(statement.Source, monetary.Amount)
if err != nil {
return err
}
return st.sendTo(statement.Destination, &amtBi)
default:
utils.NonExhaustiveMatchPanic[any](sentValue)
return nil
}
}
// PRE: overdraft >= 0
func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overdraft *big.Int, colorExpr parser.ValueExpr) (*big.Int, InterpreterError) {
if colorExpr != nil {
err := s.checkFeatureFlag(flags.ExperimentalAssetColors)
if err != nil {
return nil, err
}
}
account, err := evaluateExprAs(&s.evalEnv, accountLiteral, expectAccount)
if err != nil {
return nil, err
}
if account.Name == "world" || overdraft == nil {
return nil, InvalidUnboundedInSendAll{
Name: account.Name,
Scope: account.Scope,
}
}
color, err := s.evaluateColor(colorExpr)
if err != nil {
return nil, err
}
balance := s.CachedBalances.fetchBalance(account, s.CurrentAsset, color)
// we sent balance+overdraft
sentAmt := CalculateMaxSafeWithdraw(balance, overdraft)
s.pushSender(account, MonetaryInt(*sentAmt), color)
return sentAmt, nil
}
// Pull as much as possible (and return the sent amt)
func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError) {
switch source := source.(type) {
case *parser.SourceAccount:
return s.takeAllFromAccount(source.ValueExpr, big.NewInt(0), source.Color)
case *parser.SourceOverdraft:
var cap *big.Int
if source.Bounded != nil {
bounded, err := evaluateExprAs(&s.evalEnv, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset))
if err != nil {
return nil, err
}
boundedBi := big.Int(bounded)
cap = utils.NonNeg(&boundedBi)
}
return s.takeAllFromAccount(source.Address, cap, source.Color)
case *parser.SourceWithScaling:
err := s.checkFeatureFlag(flags.AssetScaling)
if err != nil {
return nil, err
}
account, err := evaluateExprAs(&s.evalEnv, source.Address, expectAccount)
if err != nil {
return nil, err
}
scalingAccount, err := evaluateExprAs(&s.evalEnv, source.Through, expectAccount)
if err != nil {
return nil, err
}
baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale()
acc, ok := s.CachedBalances[account]
if !ok {
return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range}
}
sol, totSent := findScalingSolution(
nil,
assetScale,
getAssets(acc, baseAsset),
)
for _, convAmt := range sol {
s.forcePushPostingUncolored(
account,
scalingAccount,
MonetaryInt(*new(big.Int).Set(convAmt.amount)),
Asset(buildScaledAsset(baseAsset, convAmt.scale)),
)
}
s.forcePushPostingUncolored(
scalingAccount,
account,
MonetaryInt(*new(big.Int).Set(totSent)),
s.CurrentAsset,
)
return s.takeAllFromAccount(source.Address, big.NewInt(0), nil)
case *parser.SourceInorder:
totalSent := big.NewInt(0)
for _, subSource := range source.Sources {
sent, err := s.takeAll(subSource)
if err != nil {
return nil, err
}
totalSent.Add(totalSent, sent)
}
return totalSent, nil
case *parser.SourceOneof:
err := s.checkFeatureFlag(flags.ExperimentalOneofFeatureFlag)
if err != nil {
return nil, err
}
// we can safely access the first one because empty oneof is parsing err
first := source.Sources[0]
return s.takeAll(first)
case *parser.SourceCapped:
monetary, err := evaluateExprAs(&s.evalEnv, source.Cap, expectMonetaryOfAsset(s.CurrentAsset))
if err != nil {
return nil, err
}
monetaryBi := big.Int(monetary)
// We switch to the default sending evaluation for this subsource
return s.tryTakingUpTo(source.From, utils.NonNeg(&monetaryBi))
case *parser.SourceAllotment:
return nil, InvalidAllotmentInSendAll{}
default:
_ = utils.NonExhaustiveMatchPanic[error](source)
return nil, nil
}
}
// Fails if it doesn't manage to pull exactly "amount"
func (s *programState) tryTakingExact(source parser.Source, amount MonetaryInt) InterpreterError {
amtBi := (*big.Int)(&amount)
sentAmt, err := s.tryTakingUpTo(source, amtBi)
if err != nil {
return err
}
if sentAmt.Cmp(amtBi) != 0 {
return MissingFundsErr{
Asset: string(s.CurrentAsset),
Needed: *amtBi,
Available: *sentAmt,
Range: source.GetRange(),
}
}
return nil
}
var colorRe = regexp.MustCompile("^[A-Z]*$")
// PRE: overdraft >= 0
func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amount *big.Int, overdraft *big.Int, colorExpr parser.ValueExpr) (*big.Int, InterpreterError) {
if colorExpr != nil {
err := s.checkFeatureFlag(flags.ExperimentalAssetColors)
if err != nil {
return nil, err
}
}
account, err := evaluateExprAs(&s.evalEnv, accountLiteral, expectAccount)
if err != nil {
return nil, err
}
if account.Name == "world" {
overdraft = nil
}
color, err := s.evaluateColor(colorExpr)
if err != nil {
return nil, err
}
var actuallySentAmt *big.Int
if overdraft == nil {
// unbounded overdraft: we send the required amount
actuallySentAmt = new(big.Int).Set(amount)
} else {
balance := s.CachedBalances.fetchBalance(account, s.CurrentAsset, color)
// that's the amount we are allowed to send (balance + overdraft)
actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount)
}
s.pushSender(account, MonetaryInt(*actuallySentAmt), color)
return actuallySentAmt, nil
}
func (s *programState) cloneState() func() {
fqBackup := s.fundsQueue.Clone()
balancesBackup := s.CachedBalances.DeepClone()
return func() {
s.fundsQueue = fqBackup
s.CachedBalances = balancesBackup
}
}
// Tries pulling up to "amount" and returns the actually pulled amt.
// Doesn't fail (unless nested sources fail)
func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*big.Int, InterpreterError) {
amount = utils.NonNeg(amount)
switch source := source.(type) {
case *parser.SourceAccount:
return s.tryTakingFromAccount(source.ValueExpr, amount, big.NewInt(0), source.Color)
case *parser.SourceWithScaling:
// Note that scaled sources are colorless (for now). That's we we don't bother including
// colors in the logic about scaling
err := s.checkFeatureFlag(flags.AssetScaling)
if err != nil {
return nil, err
}
account, err := evaluateExprAs(&s.evalEnv, source.Address, expectAccount)
if err != nil {
return nil, err
}
scalingAccount, err := evaluateExprAs(&s.evalEnv, source.Through, expectAccount)
if err != nil {
return nil, err
}
baseAsset, assetScale := s.CurrentAsset.GetBaseAndScale()
acc, ok := s.CachedBalances[account]
if !ok {
return nil, InvalidUnboundedAddressInScalingAddress{Range: source.Range}
}
sol, swappedAmt := findScalingSolution(
amount,
assetScale,
getAssets(acc, baseAsset),
)
for _, pair := range sol {
s.forcePushPostingUncolored(
account,
scalingAccount,
NewMonetaryIntBig(pair.amount),
Asset(buildScaledAsset(baseAsset, pair.scale)),
)
}
s.forcePushPostingUncolored(
scalingAccount,
account,
NewMonetaryIntBig(swappedAmt),
s.CurrentAsset,
)
return s.tryTakingFromAccount(source.Address, amount, big.NewInt(0), nil)
case *parser.SourceOverdraft:
var cap *big.Int
if source.Bounded != nil {
upTo, err := evaluateExprAs(&s.evalEnv, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset))
if err != nil {
return nil, err
}
bi := big.Int(upTo)
cap = utils.NonNeg(&bi)
}
return s.tryTakingFromAccount(source.Address, amount, cap, source.Color)
case *parser.SourceInorder:
totalLeft := new(big.Int).Set(amount)
for _, source := range source.Sources {
sentAmt, err := s.tryTakingUpTo(source, totalLeft)
if err != nil {
return nil, err
}
totalLeft.Sub(totalLeft, sentAmt)
}
return new(big.Int).Sub(amount, totalLeft), nil
case *parser.SourceOneof:
err := s.checkFeatureFlag(flags.ExperimentalOneofFeatureFlag)
if err != nil {
return nil, err
}
// empty oneof is parsing err
leadingSources := source.Sources[0 : len(source.Sources)-1]
for _, source := range leadingSources {
// do not move this line below (as .tryTakingUpTo() will mutate the fundsQueue)
undo := s.cloneState()
sentAmt, err := s.tryTakingUpTo(source, amount)
if err != nil {
return nil, err
}
// if this branch managed to sent all the required amount, return now
if sentAmt.Cmp(amount) == 0 {
return amount, nil
}
// else, backtrack to remove this branch's sendings
undo()
}
return s.tryTakingUpTo(source.Sources[len(source.Sources)-1], amount)
case *parser.SourceAllotment:
var items []parser.AllotmentValue
for _, i := range source.Items {
items = append(items, i.Allotment)
}
allot, err := s.makeAllotment(amount, items)
if err != nil {
return nil, err
}
for i, allotmentItem := range source.Items {
err := s.tryTakingExact(allotmentItem.From, MonetaryInt(*allot[i]))
if err != nil {
return nil, err
}
}
return amount, nil
case *parser.SourceCapped:
cap, err := evaluateExprAs(&s.evalEnv, source.Cap, expectMonetaryOfAsset(s.CurrentAsset))
if err != nil {
return nil, err
}
capBi := big.Int(cap)
return s.tryTakingUpTo(source.From, utils.NonNeg(
utils.MinBigInt(amount, &capBi),
))
default:
utils.NonExhaustiveMatchPanic[any](source)
return nil, nil
}
}
func (s *programState) sendTo(destination parser.Destination, amount *big.Int) InterpreterError {
switch destination := destination.(type) {
case *parser.DestinationAccount:
account, err := evaluateExprAs(&s.evalEnv, destination.ValueExpr, expectAccount)
if err != nil {
return err
}
s.pushReceiver(account, amount)
return nil
case *parser.DestinationAllotment:
var items []parser.AllotmentValue
for _, i := range destination.Items {
items = append(items, i.Allotment)
}
allot, err := s.makeAllotment(amount, items)
if err != nil {
return err
}
receivedTotal := big.NewInt(0)
for i, allotmentItem := range destination.Items {
amtToReceive := allot[i]
err := s.sendToKeptOrDest(allotmentItem.To, amtToReceive)
if err != nil {
return err
}
receivedTotal.Add(receivedTotal, amtToReceive)
}
return nil
case *parser.DestinationInorder:
remainingAmount := new(big.Int).Set(amount)
handler := func(keptOrDest parser.KeptOrDestination, amountToReceive *big.Int) InterpreterError {
if amountToReceive.Cmp(big.NewInt(0)) == 0 {
return nil
}
err := s.sendToKeptOrDest(keptOrDest, amountToReceive)
if err != nil {
return err
}
remainingAmount.Sub(remainingAmount, amountToReceive)
return err
}
for _, destinationClause := range destination.Clauses {
// If the remaining amt is zero, let's ignore the posting
if remainingAmount.Cmp(big.NewInt(0)) == 0 {
break
}
cap, err := evaluateExprAs(&s.evalEnv, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset))
if err != nil {
return err
}
capBi := big.Int(cap)
amountToReceive := utils.MaxBigInt(utils.MinBigInt(&capBi, remainingAmount), big.NewInt(0))
err = handler(destinationClause.To, amountToReceive)
if err != nil {
return err
}
}
remainingAmountCopy := new(big.Int).Set(remainingAmount)
// passing "remainingAmount" directly breaks the code
return handler(destination.Remaining, remainingAmountCopy)
case *parser.DestinationOneof:
err := s.checkFeatureFlag(flags.ExperimentalOneofFeatureFlag)
if err != nil {
return err
}
for _, destinationClause := range destination.Clauses {
cap, err := evaluateExprAs(&s.evalEnv, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset))
if err != nil {
return err
}
capBi := big.Int(cap)
// if the clause cap is >= the amount we're trying to receive, only go through this branch
switch capBi.Cmp(amount) {
case 0, 1:
return s.sendToKeptOrDest(destinationClause.To, amount)
}
// otherwise try next clause (keep looping)
}
return s.sendToKeptOrDest(destination.Remaining, amount)
default:
utils.NonExhaustiveMatchPanic[any](destination)
return nil
}
}
const KEPT_ADDR = "<kept>"
func (s *programState) sendToKeptOrDest(keptOrDest parser.KeptOrDestination, amount *big.Int) InterpreterError {
switch destinationTarget := keptOrDest.(type) {
case *parser.DestinationKept:
s.pushReceiver(AccountAddress{Name: KEPT_ADDR}, amount)
return nil
case *parser.DestinationTo:
return s.sendTo(destinationTarget.Destination, amount)
default:
utils.NonExhaustiveMatchPanic[any](destinationTarget)
return nil
}
}
func (s *programState) makeAllotment(monetary *big.Int, items []parser.AllotmentValue) ([]*big.Int, InterpreterError) {
totalAllotment := big.NewRat(0, 1)
var allotments []*big.Rat
remainingAllotmentIndex := -1
for i, item := range items {
switch allotment := item.(type) {
case *parser.ValueExprAllotment:
rat, err := evaluateExprAs(&s.evalEnv, allotment.Value, expectPortion)
if err != nil {
return nil, err
}
totalAllotment.Add(totalAllotment, rat)
allotments = append(allotments, rat)
case *parser.RemainingAllotment:
remainingAllotmentIndex = i
allotments = append(allotments, new(big.Rat))
// TODO check there are not duplicate remaining clause
}
}
if remainingAllotmentIndex != -1 {
allotments[remainingAllotmentIndex] = new(big.Rat).Sub(big.NewRat(1, 1), totalAllotment)
} else if totalAllotment.Cmp(big.NewRat(1, 1)) != 0 {
return nil, InvalidAllotmentSum{ActualSum: *totalAllotment}
}
parts := make([]*big.Int, len(allotments))
totalAllocated := big.NewInt(0)
for i, allot := range allotments {
monetaryRat := new(big.Rat).SetInt(monetary)
product := new(big.Rat).Mul(allot, monetaryRat)
floored := new(big.Int).Div(product.Num(), product.Denom())
parts[i] = floored
totalAllocated.Add(totalAllocated, floored)
}
for i := range parts {
if /* totalAllocated >= monetary */ totalAllocated.Cmp(monetary) != -1 {
break
}
parts[i].Add(parts[i], big.NewInt(1))
// totalAllocated++
totalAllocated.Add(totalAllocated, big.NewInt(1))
}
return parts, nil
}
func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, InterpreterError) {
switch sentValue := sentValue.(type) {
case *parser.SentValueAll:
asset, err := evaluateExprAs(env, sentValue.Asset, expectAsset)
if err != nil {
return "", nil, err
}
return asset, nil, nil
case *parser.SentValueLiteral:
monetary, err := evaluateExprAs(env, sentValue.Monetary, expectMonetary)
if err != nil {
return "", nil, err
}
bi := big.Int(monetary.Amount)
return monetary.Asset, &bi, nil
default:
return "", nil, unhandledErr(sentValue)
}
}
var percentRegex = regexp.MustCompile(`^([0-9]+)(?:[.]([0-9]+))?[%]$`)
var fractionRegex = regexp.MustCompile(`^([0-9]+)\s?[/]\s?([0-9]+)$`)
// slightly edited copy-paste from:
// https://github.com/formancehq/ledger/blob/b188d0c80eadaab5024d74edc967c7005e155f7c/internal/machine/portion.go#L57
func ParsePortionSpecific(input string) (*big.Rat, InterpreterError) {
var res *big.Rat
var ok bool
percentMatch := percentRegex.FindStringSubmatch(input)
if len(percentMatch) != 0 {
integral := percentMatch[1]
fractional := percentMatch[2]
res, ok = new(big.Rat).SetString(integral + "." + fractional)
if !ok {
return nil, BadPortionParsingErr{Reason: "invalid percent format", Source: input}
}
res.Mul(res, big.NewRat(1, 100))
} else {
fractionMatch := fractionRegex.FindStringSubmatch(input)
if len(fractionMatch) != 0 {
numerator := fractionMatch[1]
denominator := fractionMatch[2]
res, ok = new(big.Rat).SetString(numerator + "/" + denominator)
if !ok {
return nil, BadPortionParsingErr{Reason: "invalid fractional format", Source: input}
}
}
}
if res == nil {
return nil, BadPortionParsingErr{Reason: "invalid format", Source: input}
}
if res.Cmp(big.NewRat(0, 1)) == -1 || res.Cmp(big.NewRat(1, 1)) == 1 {
return nil, BadPortionParsingErr{Reason: "portion must be between 0% and 100% inclusive", Source: input}
}
return res, nil
}
/*
PRE: ovedraft != nil, balance != nil
PRE: ovedraft >= 0
POST: $out >= 0
*/
func CalculateMaxSafeWithdraw(balance *big.Int, overdraft *big.Int) *big.Int {
return utils.NonNeg(
new(big.Int).Add(balance, overdraft),
)
}
/*
PRE: ovedraft != nil, balance != nil
PRE: ovedraft >= 0
PRE: requestedAmount >= 0
POST: $out >= 0
*/
func CalculateSafeWithdraw(
balance *big.Int,
overdraft *big.Int,
requestedAmount *big.Int,
) *big.Int {
safe := CalculateMaxSafeWithdraw(balance, overdraft)
return utils.MinBigInt(safe, requestedAmount)
}
func PrettyPrintPostings(postings []Posting) string {
// the optional columns (scopes, color) are dropped automatically when no
// posting populates them
header := []string{"Source", "Source Scope", "Destination", "Destination Scope", "Asset", "Color", "Amount"}
var rows [][]string
for _, posting := range postings {
rows = append(rows, []string{
posting.Source,
posting.SourceScope,
posting.Destination,
posting.DestinationScope,
posting.Asset,
posting.Color,
posting.Amount.String(),
})
}
return utils.CsvPrettyOmitEmptyCols(header, rows, false)
}
func PrettyPrintMeta(meta Metadata) string {
m := map[string]string{}
for k, v := range meta {
m[k] = v.String()
}
return utils.CsvPrettyMap("Name", "Value", m)
}