Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions internal/interpreter/recording_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package interpreter

import (
"context"
"math/big"
)

// recordingStore wraps a Store and records all balance and metadata reads,
// preserving the order in which the underlying store returned them.
//
// It is used by ResolveDependencies to discover which data a script depends on.
type recordingStore struct {
inner Store
balanceReads Balances
metadataReads AccountsMetadata
}

func newRecordingStore(inner Store) *recordingStore {
return &recordingStore{
inner: inner,
balanceReads: Balances{},
metadataReads: AccountsMetadata{},
}
}

func (r *recordingStore) GetBalances(ctx context.Context, query BalanceQuery) (Balances, error) {
result, err := r.inner.GetBalances(ctx, query)
if err != nil {
return nil, err
}

for _, row := range result {
if r.balanceReads.hasRow(row.Account, row.Asset, row.Color) {
continue
}
amount := new(big.Int)
if row.Amount != nil {
amount.Set(row.Amount)
}
r.balanceReads = append(r.balanceReads, BalanceRow{
Account: row.Account,
Asset: row.Asset,
Color: row.Color,
Amount: amount,
})
}

return result, nil
}

func (r *recordingStore) GetAccountsMetadata(ctx context.Context, query MetadataQuery) (AccountsMetadata, error) {
result, err := r.inner.GetAccountsMetadata(ctx, query)
if err != nil {
return nil, err
}

for account, meta := range result {
if _, ok := r.metadataReads[account]; !ok {
r.metadataReads[account] = AccountMetadata{}
}
for key, value := range meta {
r.metadataReads[account][key] = value
}
}

return result, nil
}

func (rows Balances) hasRow(account, asset, color string) bool {
for i := range rows {
if rows[i].Account == account && rows[i].Asset == asset && rows[i].Color == color {
return true
}
}
return false
}
273 changes: 273 additions & 0 deletions internal/interpreter/resolve_dependencies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
package interpreter

import (
"context"
"maps"
"slices"

"github.com/formancehq/numscript/internal/flags"
"github.com/formancehq/numscript/internal/parser"
"github.com/formancehq/numscript/internal/utils"
)

// ResolvedDependencies summarizes what a script reads from and writes to the
// store. The caller can use it to preload data and to detect input drift
// between successive runs.
type ResolvedDependencies struct {
// Reads contains the data the script read from the store while resolving.
Reads ResolvedReads

// Writes contains the (account, asset, color) tuples whose balance can be
// impacted by a posting emitted by the script.
Writes ResolvedWrites
}

// ResolvedReads holds the data read from the store while resolving the
// script's dependencies.
type ResolvedReads struct {
// Volumes contains every (account, asset, color) → balance row read from
// the store, in the order it was returned.
Volumes Balances

// Metadata contains all (account, key) → value pairs read from the store.
Metadata AccountsMetadata
}

// ResolvedWrites holds the data the script may write to the store.
type ResolvedWrites struct {
// Volumes lists every (account, asset, color) tuple that may be impacted
// by a posting emitted by the script.
Volumes BalanceQuery
}

// ResolveDependenciesOptions configures ResolveDependencies behavior.
type ResolveDependenciesOptions struct {
// FeatureFlags enables additional experimental features
// (same semantics as RunWithFeatureFlags).
FeatureFlags map[string]struct{}
}

// ResolveDependencies discovers which data a script reads from the store and
// which (account, asset, color) tuples it may write to, without executing any
// posting.
//
// It performs variable resolution and source preloading — the two phases that
// RunProgram runs before executing statements — then walks the send statements
// to collect the touched accounts. No transfers are simulated, so the call is
// cheap and does not depend on the script's runtime semantics (allotments,
// overdraft, etc.).
//
// Store calls (GetBalances/GetAccountsMetadata) are issued in a deterministic
// order across runs with identical inputs, so the caller can hash them to
// detect input drift.
func ResolveDependencies(
ctx context.Context,
program parser.Program,
vars map[string]string,
store Store,
opts ResolveDependenciesOptions,
) (*ResolvedDependencies, InterpreterError) {
recorder := newRecordingStore(store)

featureFlags := maps.Clone(opts.FeatureFlags)
if featureFlags == nil {
featureFlags = make(map[string]struct{}, len(program.Flags))
}
for _, flag := range program.Flags {
if slices.Index(flags.AllFlags, flag.String) == -1 {
return nil, InvalidFeature{Feature: flag.String}
}
featureFlags[flag.String] = struct{}{}
}

st := programState{
ParsedVars: make(map[string]Value),
TxMeta: make(map[string]Value),
CachedAccountsMeta: AccountsMetadata{},
CachedBalances: InternalBalances{},
SetAccountsMeta: AccountsMetadata{},
Store: recorder,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Postings: make([]Posting, 0),
fundsQueue: newFundsQueue(nil),
CurrentBalanceQuery: BalanceQuery{},
ctx: ctx,
FeatureFlags: featureFlags,
}

st.varOriginPosition = true
if program.Vars != nil {
if err := st.parseVars(program.Vars.Declarations, vars); err != nil {
return nil, err
}
}
st.varOriginPosition = false

for _, statement := range program.Statements {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] Include reads from function and cap expressions

For scripts where a store-reading expression appears outside variable declarations, sent values, or account address expressions — for example set_tx_meta("x", meta(@cfg, "k")) or a source/destination cap using balance(@limits, USD/2)Run will evaluate those expressions and read the store, but this resolver only performs the balance preload pass here and never evaluates those statement/cap/allotment expressions. The returned Reads can therefore omit inputs that affect execution, so the admission/FSM drift hash can miss metadata or balance changes.

if err := st.findBalancesQueriesInStatement(statement); err != nil {
return nil, err
}
}
if err := st.runBalancesQuery(); err != nil {
return nil, QueryBalanceError{WrappedError: err}
}

writes := BalanceQuery{}
for _, statement := range program.Statements {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [blocker] Include execution-time store reads

For scripts where store-backed functions are evaluated during statement execution rather than source preloading, such as set_tx_meta("k", meta(@cfg, "v")) or caps/allotments using balance(...), Run will call the store but this path only preloads source balances and then walks send statements without executing/evaluating those expressions. In those cases ResolveDependencies omits real metadata/balance reads from the admission hash, so FSM drift can go undetected.

send, ok := statement.(*parser.SendStatement)
if !ok {
continue
}
if err := st.collectSendWrites(*send, &writes); err != nil {
return nil, err
}
}

return &ResolvedDependencies{
Reads: ResolvedReads{
Volumes: recorder.balanceReads,
Metadata: recorder.metadataReads,
},
Writes: ResolvedWrites{Volumes: writes},
}, nil
}

func (st *programState) collectSendWrites(
send parser.SendStatement,
writes *BalanceQuery,
) InterpreterError {
asset, _, err := st.evaluateSentAmt(send.SentValue)
if err != nil {
return err
}
st.CurrentAsset = asset

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err := st.collectSourceWrites(send.Source, writes); err != nil {
return err
}
return st.collectDestinationWrites(send.Destination, writes)
}

func (st *programState) collectSourceWrites(
source parser.Source,
writes *BalanceQuery,
) InterpreterError {
switch source := source.(type) {
case *parser.SourceAccount:
return st.touchAccount(source.ValueExpr, source.Color, writes)

case *parser.SourceOverdraft:
return st.touchAccount(source.Address, source.Color, writes)

case *parser.SourceWithScaling:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Record scaling swap balance writes

When a source uses with scaling through @swap, runtime emits conversion postings from the source to the swap account in scaled asset(s) and from the swap account back to the source before the final send. This records only the source/current asset, so callers relying on Writes.Volumes will miss the swap account and scaled source assets that are actually impacted for asset-scaling scripts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Record scaling writes for swap and scaled assets

When the script uses source = @src with scaling through @swap, the interpreter can emit postings from src in the consumed scaled asset rows and from swap in the target asset before the final send. This branch records only src with CurrentAsset, so Writes.Volumes misses balances such as src/EUR and swap/EUR/2; callers using writes for preloading or conflict detection will not protect balances the script can update.

return st.touchAccount(source.Address, nil, writes)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
case *parser.SourceInorder:
for _, sub := range source.Sources {
if err := st.collectSourceWrites(sub, writes); err != nil {
return err
}
}
return nil

case *parser.SourceOneof:
for _, sub := range source.Sources {
if err := st.collectSourceWrites(sub, writes); err != nil {
return err
}
}
return nil

case *parser.SourceCapped:
return st.collectSourceWrites(source.From, writes)

case *parser.SourceAllotment:
for _, item := range source.Items {
if err := st.collectSourceWrites(item.From, writes); err != nil {
return err
}
}
return nil

default:
utils.NonExhaustiveMatchPanic[any](source)
return nil
}
}

func (st *programState) collectDestinationWrites(
dest parser.Destination,
writes *BalanceQuery,
) InterpreterError {
switch dest := dest.(type) {
case *parser.DestinationAccount:
return st.touchAccount(dest.ValueExpr, nil, writes)

case *parser.DestinationInorder:
for _, clause := range dest.Clauses {
if err := st.collectKeptOrDestWrites(clause.To, writes); err != nil {
return err
}
}
return st.collectKeptOrDestWrites(dest.Remaining, writes)

case *parser.DestinationOneof:
for _, clause := range dest.Clauses {
if err := st.collectKeptOrDestWrites(clause.To, writes); err != nil {
return err
}
}
return st.collectKeptOrDestWrites(dest.Remaining, writes)

case *parser.DestinationAllotment:
for _, item := range dest.Items {
if err := st.collectKeptOrDestWrites(item.To, writes); err != nil {
return err
}
}
return nil

default:
utils.NonExhaustiveMatchPanic[any](dest)
return nil
}
}

func (st *programState) collectKeptOrDestWrites(
k parser.KeptOrDestination,
writes *BalanceQuery,
) InterpreterError {
switch k := k.(type) {
case *parser.DestinationKept:
return nil
case *parser.DestinationTo:
return st.collectDestinationWrites(k.Destination, writes)
default:
utils.NonExhaustiveMatchPanic[any](k)
return nil
}
}

func (st *programState) touchAccount(
accountExpr parser.ValueExpr,
colorExpr parser.ValueExpr,
writes *BalanceQuery,
) InterpreterError {
account, err := evaluateExprAs(st, accountExpr, expectAccount)
if err != nil {
return err
}
color, err := evaluateOptExprAs(st, colorExpr, expectString)
if err != nil {
return err
}

item := BalanceQueryItem{
Account: string(account),
Asset: string(st.CurrentAsset),
Color: string(color),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if !slices.Contains(*writes, item) {
*writes = append(*writes, item)
}
return nil
}
Loading