mirror of
https://github.com/hanzoai/numscript.git
synced 2026-10-30 18:26:57 +00:00
1205 lines
30 KiB
Go
1205 lines
30 KiB
Go
package interpreter
|
|
|
|
import (
|
|
"context"
|
|
"maps"
|
|
"math/big"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/formancehq/numscript/internal/analysis"
|
|
"github.com/formancehq/numscript/internal/flags"
|
|
"github.com/formancehq/numscript/internal/parser"
|
|
"github.com/formancehq/numscript/internal/utils"
|
|
)
|
|
|
|
type VariablesMap map[string]string
|
|
|
|
// For each account, list of the needed assets
|
|
type BalanceQuery map[string][]string
|
|
|
|
// For each account, list of the needed keys
|
|
type MetadataQuery map[string][]string
|
|
|
|
type AccountBalance = map[string]*big.Int
|
|
type Balances map[string]AccountBalance
|
|
|
|
type AccountMetadata = map[string]string
|
|
type AccountsMetadata map[string]AccountMetadata
|
|
|
|
type Store interface {
|
|
GetBalances(context.Context, BalanceQuery) (Balances, error)
|
|
GetAccountsMetadata(context.Context, MetadataQuery) (AccountsMetadata, error)
|
|
}
|
|
|
|
type StaticStore struct {
|
|
Balances Balances
|
|
Meta AccountsMetadata
|
|
}
|
|
|
|
func (s StaticStore) GetBalances(_ context.Context, q BalanceQuery) (Balances, error) {
|
|
if s.Balances == nil {
|
|
s.Balances = Balances{}
|
|
}
|
|
|
|
outputBalance := Balances{}
|
|
for queriedAccount, queriedCurrencies := range q {
|
|
outputAccountBalance := AccountBalance{}
|
|
outputBalance[queriedAccount] = outputAccountBalance
|
|
|
|
accountBalanceLookup := utils.MapGetOrPutDefault(s.Balances, queriedAccount, func() AccountBalance {
|
|
return AccountBalance{}
|
|
})
|
|
|
|
for _, curr := range queriedCurrencies {
|
|
baseAsset, isCatchAll := strings.CutSuffix(curr, "/*")
|
|
if isCatchAll {
|
|
|
|
for k, v := range accountBalanceLookup {
|
|
matchesAsset := k == baseAsset || strings.HasPrefix(k, baseAsset+"/")
|
|
if !matchesAsset {
|
|
continue
|
|
}
|
|
outputAccountBalance[k] = new(big.Int).Set(v)
|
|
}
|
|
|
|
} else {
|
|
n := new(big.Int)
|
|
outputAccountBalance[curr] = n
|
|
|
|
if i, ok := accountBalanceLookup[curr]; ok {
|
|
n.Set(i)
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
return outputBalance, nil
|
|
}
|
|
|
|
func (s StaticStore) GetAccountsMetadata(context.Context, MetadataQuery) (AccountsMetadata, error) {
|
|
if s.Meta == nil {
|
|
s.Meta = AccountsMetadata{}
|
|
}
|
|
return s.Meta, nil
|
|
}
|
|
|
|
type InterpreterError interface {
|
|
error
|
|
parser.Ranged
|
|
}
|
|
|
|
type Metadata = map[string]Value
|
|
|
|
type Posting struct {
|
|
Source string `json:"source"`
|
|
Destination string `json:"destination"`
|
|
Amount *big.Int `json:"amount"`
|
|
Asset string `json:"asset"`
|
|
}
|
|
|
|
type ExecutionResult struct {
|
|
Postings []Posting `json:"postings"`
|
|
|
|
Metadata Metadata `json:"txMeta"`
|
|
|
|
AccountsMetadata AccountsMetadata `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 (s *programState) handleFnOrigin(type_ string, expr parser.ValueExpr) (Value, InterpreterError) {
|
|
// Special case for top-level meta() call
|
|
if fnCall, ok := expr.(*parser.FnCall); ok && fnCall.Caller.Name == analysis.FnVarOriginMeta {
|
|
return s.handleFnCall(&type_, *fnCall)
|
|
}
|
|
|
|
if _, isFnCall := expr.(*parser.FnCall); !isFnCall {
|
|
err := s.checkFeatureFlag(flags.ExperimentalMidScriptFunctionCall)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return s.evaluateExpr(expr)
|
|
}
|
|
|
|
func (s *programState) handleFnCall(type_ *string, fnCall parser.FnCall) (Value, InterpreterError) {
|
|
args, err := s.evaluateExpressions(fnCall.Args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch fnCall.Caller.Name {
|
|
case analysis.FnVarOriginMeta:
|
|
if type_ == nil {
|
|
return nil, InvalidNestedMeta{}
|
|
}
|
|
|
|
rawValue, err := meta(s, fnCall.Range, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseVar(*type_, rawValue, fnCall.Range)
|
|
|
|
case analysis.FnVarOriginBalance:
|
|
monetary, err := balance(s, fnCall.Range, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return *monetary, nil
|
|
|
|
case analysis.FnVarOriginOverdraft:
|
|
monetary, err := overdraft(s, fnCall.Range, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return *monetary, nil
|
|
|
|
case analysis.FnVarOriginGetAsset:
|
|
return getAsset(s, fnCall.Range, args)
|
|
case analysis.FnVarOriginGetAmount:
|
|
return getAmount(s, fnCall.Range, args)
|
|
|
|
default:
|
|
return nil, UnboundFunctionErr{Name: fnCall.Caller.Name}
|
|
}
|
|
|
|
}
|
|
|
|
func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[string]string) InterpreterError {
|
|
for _, varsDecl := range varDeclrs {
|
|
if varsDecl.Origin == nil {
|
|
raw, ok := rawVars[varsDecl.Name.Name]
|
|
if !ok {
|
|
return MissingVariableErr{Name: varsDecl.Name.Name}
|
|
}
|
|
|
|
parsed, err := parseVar(varsDecl.Type.Name, raw, varsDecl.Type.Range)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.ParsedVars[varsDecl.Name.Name] = parsed
|
|
} else {
|
|
value, err := s.handleFnOrigin(varsDecl.Type.Name, *varsDecl.Origin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.ParsedVars[varsDecl.Name.Name] = value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// 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) {
|
|
|
|
st := programState{
|
|
ParsedVars: make(map[string]Value),
|
|
TxMeta: make(map[string]Value),
|
|
CachedAccountsMeta: AccountsMetadata{},
|
|
CachedBalances: Balances{},
|
|
SetAccountsMeta: AccountsMetadata{},
|
|
Store: store,
|
|
Postings: make([]Posting, 0),
|
|
fundsQueue: newFundsQueue(nil),
|
|
|
|
CurrentBalanceQuery: BalanceQuery{},
|
|
ctx: ctx,
|
|
FeatureFlags: maps.Clone(featureFlags),
|
|
}
|
|
|
|
if st.FeatureFlags == nil {
|
|
st.FeatureFlags = 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,
|
|
}
|
|
}
|
|
|
|
st.FeatureFlags[flag.String] = struct{}{}
|
|
}
|
|
|
|
st.varOriginPosition = true
|
|
if program.Vars != nil {
|
|
err := st.parseVars(program.Vars.Declarations, vars)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
st.varOriginPosition = false
|
|
|
|
// 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,
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type programState struct {
|
|
ctx context.Context
|
|
|
|
varOriginPosition bool
|
|
|
|
// Asset of the send statement currently being executed.
|
|
//
|
|
// its value is undefined outside of send statements execution
|
|
CurrentAsset string
|
|
|
|
ParsedVars map[string]Value
|
|
TxMeta map[string]Value
|
|
Postings []Posting
|
|
fundsQueue fundsQueue
|
|
|
|
Store Store
|
|
|
|
SetAccountsMeta AccountsMetadata
|
|
|
|
CachedAccountsMeta AccountsMetadata
|
|
CachedBalances Balances
|
|
|
|
CurrentBalanceQuery BalanceQuery
|
|
|
|
FeatureFlags map[string]struct{}
|
|
}
|
|
|
|
func (st *programState) pushSender(name string, monetary *big.Int, color string) {
|
|
if monetary.Cmp(big.NewInt(0)) == 0 {
|
|
return
|
|
}
|
|
|
|
balance := st.CachedBalances.fetchBalance(name, st.CurrentAsset, color)
|
|
balance.Sub(balance, monetary)
|
|
|
|
st.fundsQueue.Push(Sender{Name: name, Amount: monetary, Color: color})
|
|
}
|
|
|
|
// Append a posting without checking if account has enough balance.
|
|
// Updates both source and destination balances.
|
|
// Noop if the amount is zero
|
|
//
|
|
// PRE: posting's asset is uncolored
|
|
func (st *programState) forcePushPostingUncolored(posting Posting) {
|
|
if posting.Amount.Sign() == 0 {
|
|
return
|
|
}
|
|
|
|
srcBalance := st.CachedBalances.fetchBalance(posting.Source, posting.Asset, "")
|
|
srcBalance.Sub(srcBalance, posting.Amount)
|
|
|
|
destBalance := st.CachedBalances.fetchBalance(posting.Destination, posting.Asset, "")
|
|
destBalance.Add(destBalance, posting.Amount)
|
|
|
|
st.Postings = append(st.Postings, posting)
|
|
}
|
|
|
|
func (st *programState) pushReceiver(name string, monetary *big.Int) {
|
|
if monetary.Cmp(big.NewInt(0)) == 0 {
|
|
return
|
|
}
|
|
|
|
senders := st.fundsQueue.PullAnything(monetary)
|
|
|
|
for _, sender := range senders {
|
|
postings := Posting{
|
|
Source: sender.Name,
|
|
Destination: name,
|
|
Asset: coloredAsset(st.CurrentAsset, &sender.Color),
|
|
Amount: sender.Amount,
|
|
}
|
|
|
|
if name == KEPT_ADDR {
|
|
// If funds are kept, give them back to senders
|
|
srcBalance := st.CachedBalances.fetchBalance(postings.Source, st.CurrentAsset, sender.Color)
|
|
srcBalance.Add(srcBalance, postings.Amount)
|
|
|
|
continue
|
|
}
|
|
|
|
destBalance := st.CachedBalances.fetchBalance(postings.Destination, st.CurrentAsset, sender.Color)
|
|
destBalance.Add(destBalance, postings.Amount)
|
|
|
|
st.Postings = append(st.Postings, postings)
|
|
}
|
|
}
|
|
|
|
func (st *programState) runStatement(statement parser.Statement) InterpreterError {
|
|
switch statement := statement.(type) {
|
|
case *parser.FnCall:
|
|
args, err := st.evaluateExpressions(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 := st.evaluateSentAmt(saveStatement.SentValue)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
account, err := evaluateExprAs(st, 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, 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, sentValue.Monetary, expectMonetary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st.CurrentAsset = string(monetary.Asset)
|
|
|
|
monetaryAmt := (*big.Int)(&monetary.Amount)
|
|
if monetaryAmt.Cmp(big.NewInt(0)) == -1 {
|
|
return NegativeAmountErr{Amount: monetary.Amount}
|
|
}
|
|
|
|
err = st.tryTakingExact(statement.Source, monetaryAmt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
amt := big.Int(monetary.Amount)
|
|
return st.sendTo(statement.Destination, &amt)
|
|
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, accountLiteral, expectAccount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if *account == "world" || overdraft == nil {
|
|
return nil, InvalidUnboundedInSendAll{
|
|
Name: *account,
|
|
}
|
|
}
|
|
|
|
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, 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, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cap = utils.NonNeg(bounded)
|
|
}
|
|
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, source.Address, expectAccount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
scalingAccount, err := evaluateExprAs(s, source.Through, expectAccount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseAsset, assetScale := getAssetScale(s.CurrentAsset)
|
|
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(Posting{
|
|
Source: *account,
|
|
Destination: *scalingAccount,
|
|
Amount: new(big.Int).Set(convAmt.amount),
|
|
Asset: buildScaledAsset(baseAsset, convAmt.scale),
|
|
})
|
|
}
|
|
|
|
s.forcePushPostingUncolored(Posting{
|
|
Source: *scalingAccount,
|
|
Destination: *account,
|
|
Amount: new(big.Int).Set(totSent),
|
|
Asset: s.CurrentAsset,
|
|
})
|
|
|
|
return s.takeAllFromAccount(source.Address, big.NewInt(0), source.Color)
|
|
|
|
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, source.Cap, expectMonetaryOfAsset(s.CurrentAsset))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// We switch to the default sending evaluation for this subsource
|
|
return s.tryTakingUpTo(source.From, utils.NonNeg(monetary))
|
|
|
|
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 *big.Int) InterpreterError {
|
|
sentAmt, err := s.tryTakingUpTo(source, amount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sentAmt.Cmp(amount) != 0 {
|
|
return MissingFundsErr{
|
|
Asset: s.CurrentAsset,
|
|
Needed: *amount,
|
|
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, accountLiteral, expectAccount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if *account == "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, 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) {
|
|
switch source := source.(type) {
|
|
case *parser.SourceAccount:
|
|
return s.tryTakingFromAccount(source.ValueExpr, amount, big.NewInt(0), source.Color)
|
|
|
|
case *parser.SourceWithScaling:
|
|
err := s.checkFeatureFlag(flags.AssetScaling)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
account, err := evaluateExprAs(s, source.Address, expectAccount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
scalingAccount, err := evaluateExprAs(s, source.Through, expectAccount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseAsset, assetScale := getAssetScale(s.CurrentAsset)
|
|
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(Posting{
|
|
Source: *account,
|
|
Destination: *scalingAccount,
|
|
Amount: new(big.Int).Set(pair.amount),
|
|
Asset: buildScaledAsset(baseAsset, pair.scale),
|
|
})
|
|
}
|
|
|
|
s.forcePushPostingUncolored(Posting{
|
|
Source: *scalingAccount,
|
|
Destination: *account,
|
|
Amount: new(big.Int).Set(swappedAmt),
|
|
Asset: s.CurrentAsset,
|
|
})
|
|
|
|
return s.tryTakingFromAccount(source.Address, amount, big.NewInt(0), source.Color)
|
|
|
|
case *parser.SourceOverdraft:
|
|
var cap *big.Int
|
|
if source.Bounded != nil {
|
|
upTo, err := evaluateExprAs(s, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cap = utils.NonNeg(upTo)
|
|
}
|
|
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, allot[i])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return amount, nil
|
|
|
|
case *parser.SourceCapped:
|
|
cap, err := evaluateExprAs(s, source.Cap, expectMonetaryOfAsset(s.CurrentAsset))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.tryTakingUpTo(source.From, utils.NonNeg(
|
|
utils.MinBigInt(amount, cap),
|
|
))
|
|
|
|
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, 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 {
|
|
|
|
cap, err := evaluateExprAs(s, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the remaining amt is zero, let's ignore the posting
|
|
if remainingAmount.Cmp(big.NewInt(0)) == 0 {
|
|
break
|
|
}
|
|
|
|
amountToReceive := utils.MaxBigInt(utils.MinBigInt(cap, 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, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// if the clause cap is >= the amount we're trying to receive, only go through this branch
|
|
switch cap.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(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, 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
|
|
}
|
|
|
|
// Utility function to get the balance
|
|
func getBalance(
|
|
s *programState,
|
|
account string,
|
|
asset string,
|
|
) (*big.Int, InterpreterError) {
|
|
s.batchQuery(account, asset, nil)
|
|
fetchBalanceErr := s.runBalancesQuery()
|
|
if fetchBalanceErr != nil {
|
|
return nil, QueryBalanceError{WrappedError: fetchBalanceErr}
|
|
}
|
|
balance := s.CachedBalances.fetchBalance(account, asset, "")
|
|
return balance, nil
|
|
|
|
}
|
|
|
|
func (st *programState) evaluateSentAmt(sentValue parser.SentValue) (*string, *big.Int, InterpreterError) {
|
|
switch sentValue := sentValue.(type) {
|
|
case *parser.SentValueAll:
|
|
asset, err := evaluateExprAs(st, sentValue.Asset, expectAsset)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return asset, nil, nil
|
|
|
|
case *parser.SentValueLiteral:
|
|
monetary, err := evaluateExprAs(st, sentValue.Monetary, expectMonetary)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
s := string(monetary.Asset)
|
|
bi := big.Int(monetary.Amount)
|
|
return &s, &bi, nil
|
|
|
|
default:
|
|
utils.NonExhaustiveMatchPanic[any](sentValue)
|
|
return nil, nil, nil
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s programState) checkFeatureFlag(flag string) InterpreterError {
|
|
_, ok := s.FeatureFlags[flag]
|
|
if ok {
|
|
return nil
|
|
} else {
|
|
return ExperimentalFeature{FlagName: flag}
|
|
}
|
|
}
|
|
|
|
/*
|
|
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 {
|
|
var rows [][]string
|
|
for _, posting := range postings {
|
|
row := []string{posting.Source, posting.Destination, posting.Asset, posting.Amount.String()}
|
|
rows = append(rows, row)
|
|
}
|
|
return utils.CsvPretty([]string{"Source", "Destination", "Asset", "Amount"}, 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)
|
|
}
|