Repository navigation
Tags: formancehq/numscript
Tags
feat: replace the funds execution engine with internal/funds (#190) * refactor: extract the funds engine into internal/funds Moves the funds queue, the balance cache, the value validators/parsers and the asset-scaling helpers out of internal/interpreter into a standalone internal/funds package, behind a RunState type. The interpreter becomes a consumer of it rather than the owner. This is the shared substrate the forthcoming bytecode VM needs: it must execute the same funds semantics without depending on the tree-walking interpreter. internal/funds/funds.go RunState: the source queue, the balance cache, mark/rewind, postings internal/funds/values.go account/asset/color/scope validators and parsers internal/funds/scaling.go was interpreter/asset_scaling.go internal/funds/metadata.go AccountsMetadata Behavior changes, each pinned by a spec in this commit: neg-max-dest a negative amount in a destination max clause now fails with NegativeAmountErr instead of emitting a posting. scaling-with-oneof a oneof branch that falls short now discards its scaled-swap postings, via RunState's mark/rewind. midscript-balance-after-credit a mid-script balance() read is no longer masked by an earlier write to the same account. The old InternalBalances.has() could not tell "fetched from the store" from "created by a write", so crediting @alice and then reading balance(@alice) skipped the fetch and reported only what the script sent, dropping the starting balance. funds.balanceEntry now carries baseLoaded separately from the amount: Has() reports false for an entry holding only a write delta, and Prewarm folds the fetched base into that delta instead of overwriting it. The balance() change is not breaking: mid-script calls are gated behind experimental-mid-script-function-call. balance() inside a vars block is unaffected, since vars resolve before execution and no write can mask them. Ledger implementors will see one store-call difference: the balance read is now a lazy fetch where there was none, so GetBalancesCalls in TestMidscriptBalance goes from nil to a single fetch. internal/specs_format exports four helpers that were package-private (MergeBalances, EndBalances, GetMovements, CompareMovements). They have no consumer in this commit; the differential-testing harness will use them. * fix: keep real balance values during dependency resolution The injected getBalance for dependency resolution returned zero, on the stated assumption that a balance() yields a Monetary and so cannot name an account. get_amount breaks that assumption: it turns a balance into a number, and an interpolated account can be built from that number. So for vars { monetary $b = balance(@treasury, USD) number $id = get_amount($b) } send [USD 1] ( source = @user:$id allowing unbounded overdraft destination = @out ) resolution reported a write to user:0 while execution posted from user:100 — a ledger pre-locking or prefetching from the resolved set would act on the wrong account. main is unaffected: there, resolution and execution shared one getBalance that returned the fetched value. Returns the fetched amount instead, and adds the case to TestResolveDependenciesCoversRuntime, which asserts resolution covers what execution touches. Confirmed the case fails without the fix. The differential harness cannot catch this class: internal/gen emits no account interpolation. * fix: fail closed on a negative asset-scaling conversion ForcePosting treated any non-positive amount as a no-op. FindScalingSolution can return a negative conversion when the source holds a negative balance at one scale, and dropping that leg while posting the positive ones moves money one way without the compensating move back. For @ACC1 holding EUR/2 -100 and EUR/1 20: send [EUR 1] ( source = @ACC1 with scaling through @swap destination = @DesT ) the solution is the pair (-100, 20), netting the 1 EUR asked for. Discarding the negative leg left acc1 paying EUR/1 20 — 2 EUR — to move 1 EUR, with @swap keeping the difference, and the script reported success. main fails this script: the negative conversion became a negative posting and checkPostingInvariants rejected it. That check still exists here, but the posting no longer reaches it, because ForcePosting discarded it first. ForcePosting now returns ErrNegativePosting for a negative amount (zero stays a no-op), and the interpreter maps it to the same InternalError, so the user-visible behavior matches main exactly, error message included. This is a fail-closed path, not a computed result: the underlying question of whether FindScalingSolution should produce such a solution at all is left alone deliberately — answering it would change the scaling semantics rather than restore the invariant. * fix: clamp a negative destination max clause to zero instead of erroring Reverts the funds-engine rewrite's neg-max-dest change. A negative cap on a destination max clause now behaves like the source-side one (tryTakingUpTo/NonNeg): clamped to zero rather than NegativeAmountErr, matching ledger's own gap on this shape (oracle/DIVERGENCES.md #4) and restoring what internal/difftest's TestDestinationSideNegativeMaxTolerated already expects. * fix: keep the scaling prefetch from masking other assets' balances AccountBalances folded the base of every tracked entry of the account, against the interpreter's zero-backed store. A delta-only entry for an asset outside the scaled family got stamped base-loaded with a zero base, so a later mid-script balance() skipped its fetch and reported only the script's own writes. Filter to the base-asset family the scaling prefetch actually covers; pinned by the scaling-prefetch-midscript-balance fixture.
PreviousNext