diff --git a/go.mod b/go.mod index de06fcfac7..7cef9d3192 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 github.com/bytedance/sonic v1.15.0 - github.com/formancehq/numscript v0.0.24 + github.com/formancehq/numscript v0.0.25-0.20260608064954-1fcd4893eafe github.com/go-chi/chi/v5 v5.2.5 github.com/go-jose/go-jose/v4 v4.1.4 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -164,7 +164,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/getkin/kin-openapi v0.134.0 // indirect - github.com/getsentry/sentry-go v0.35.1 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-faster/city v1.0.1 // indirect @@ -303,7 +303,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index e456ae25da..e99761834a 100644 --- a/go.sum +++ b/go.sum @@ -242,24 +242,22 @@ github.com/formancehq/go-libs/v5 v5.7.0 h1:2Z2S3vtOJr45tKpofhPqd0qKrPr6KB/LZZ+Ef github.com/formancehq/go-libs/v5 v5.7.0/go.mod h1:+jfCYWJ4Z10NGbhmbfon0hGoLe5pysbVQgePrg8M8W4= github.com/formancehq/invariants v0.11.0 h1:AHFBhK7U8Ak/qoJwgaNU+cAb9w61Ba7dwlcrJTkWBE4= github.com/formancehq/invariants v0.11.0/go.mod h1:ywzSexCUdhw2dC9Njiv0t4u2KfPVKj6SecFujPgmXno= -github.com/formancehq/numscript v0.0.24 h1:YBiDZ9zLVxTZVhtQ+taRcb6q2jArAvznWMfoWRVYGT0= -github.com/formancehq/numscript v0.0.24/go.mod h1:hC/VY5Vg04F5QkgdPPc6z/YsS/vh8V1qVJVa1VWnYMA= +github.com/formancehq/numscript v0.0.25-0.20260608064954-1fcd4893eafe h1:CL92PtU4vx7NGoTj6yohop09p8t5e4qpNWLSoPd24zM= +github.com/formancehq/numscript v0.0.25-0.20260608064954-1fcd4893eafe/go.mod h1:vQZOSwJItpYeCwUYNtV7bwu9naQoh2pdkkqs0ewkTiI= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= -github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ= -github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= -github.com/gkampitakis/ciinfo v0.3.3 h1:28PgAHtW3wG7UCAKuCK+17rBib9iqtLjajuWsVLUPQY= -github.com/gkampitakis/ciinfo v0.3.3/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z0KY= +github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-snaps v0.5.21 h1:SvhSFeZviQXwlT+dnGyAIATVehkhqRVW6qfQZhCZH+Y= +github.com/gkampitakis/go-snaps v0.5.21/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -297,8 +295,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -435,8 +433,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= +github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -774,8 +772,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/domain/processing/numscript/emulate.go b/internal/domain/processing/numscript/emulate.go index b73551c0d9..aa5d50335b 100644 --- a/internal/domain/processing/numscript/emulate.go +++ b/internal/domain/processing/numscript/emulate.go @@ -6,6 +6,7 @@ import ( "math/big" numscriptlib "github.com/formancehq/numscript" + "github.com/formancehq/numscript/accounts" "github.com/formancehq/ledger/v3/internal/domain" ) @@ -154,6 +155,48 @@ func DiscoverNumscriptDependencies(cache *NumscriptCache, script string, vars ma } } + // Emulation gap fill: the discovery store returns fake infinite (positive) + // balances, so a balance-dependent amount can collapse to zero. In particular + // overdraft(@acc, A) folds to zero against a positive balance, turning + // `send overdraft(...)` into a zero-amount send that emits no posting. The + // source account then appears in neither queriedVolumes nor the postings and + // is missed entirely, causing "read of undeclared key" at execution + // (formancehq/ledger#1500). + // + // GetInvolvedAccounts statically walks the AST and reports every account each + // send touches — including `... allowing unbounded overdraft` sources — + // regardless of the resolved amount. We union the statically-resolvable pairs + // that emulation did not already classify as destinations into sourceVolumes. + // Over-preloading is safe (it only locks an extra row); under-preloading is + // the bug. Accounts already in destinationVolumes are skipped so the + // source/destination partition is preserved for scripts emulation handles. + // + // Best-effort: GetInvolvedAccounts can legitimately error on experimental + // constructs it does not model yet (e.g. asset scaling / colored sources) + // AND the interpreter can still panic on open issues (see e.g. + // formancehq/numscript#153). safeGetInvolvedAccounts degrades a panic to + // the same nil result as an error, so admission never rejects a script that + // emulation already accepted. + if involved, involvedErr := safeGetInvolvedAccounts(parsed, variablesMap); involvedErr == nil { + for _, ia := range involved { + account, okAccount := resolveInvolvedAccount(ia.AccountExpr) + asset, okAsset := resolveInvolvedAsset(ia.AssetExpr) + if !okAccount || !okAsset { + continue + } + + key := domain.VolumeKey{ + AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: account}, + Asset: asset, + } + if _, isDestination := destinationVolumes[key]; isDestination { + continue + } + + sourceVolumes[key] = struct{}{} + } + } + // Collect account metadata keys written via set_account_meta. var writtenMetadata map[domain.MetadataKey]struct{} if len(execResult.AccountsMetadata) > 0 { @@ -174,3 +217,81 @@ func DiscoverNumscriptDependencies(cache *NumscriptCache, script string, vars ma WrittenMetadata: writtenMetadata, }, nil } + +// resolveInvolvedAccount folds an involved-account name expression to a concrete +// account string. Variables are already substituted (they are bound in +// variablesMap during GetInvolvedAccounts) and meta() is rejected earlier, so +// the only shapes that reach here are literals and concatenations of them. +// Returns false for anything it cannot statically resolve, so the caller skips +// it rather than preloading a wrong key. +func resolveInvolvedAccount(expr accounts.InvolvedAccountExpr) (string, bool) { + switch e := expr.(type) { + case accounts.AccountLiteral: + return e.Account, true + case accounts.StringLiteral: + return e.String, true + case accounts.NumberLiteral: + if e.Amount == nil { + return "", false + } + + return e.Amount.String(), true + case accounts.ConcatAccount: + left, ok := resolveInvolvedAccount(e.Left) + if !ok { + return "", false + } + right, ok := resolveInvolvedAccount(e.Right) + if !ok { + return "", false + } + + return left + right, true + } + + return "", false +} + +// resolveInvolvedAsset folds an involved-account asset expression to a concrete +// asset string. GetAsset is transparent, and the asset of a monetary produced by +// balance()/overdraft()/a monetary literal is its asset argument, so the whole +// chain (e.g. GetAsset{GetOverdraft{@credit, USD/2}}) collapses to the literal +// "USD/2". Monetary arithmetic (Add/Sub/Div/SubPrefix) is asset-preserving — +// numscript requires operands to share an asset — so we recurse into either +// operand. Returns false for shapes it cannot statically resolve. +func resolveInvolvedAsset(expr accounts.InvolvedAccountExpr) (string, bool) { + switch e := expr.(type) { + case accounts.AssetLiteral: + return e.Asset, true + case accounts.MakeMonetary: + return resolveInvolvedAsset(e.Asset) + case accounts.GetBalance: + return resolveInvolvedAsset(e.Asset) + case accounts.GetOverdraft: + return resolveInvolvedAsset(e.Asset) + case accounts.GetAsset: + return resolveInvolvedAsset(e.Monetary) + case accounts.Add: + if asset, ok := resolveInvolvedAsset(e.Left); ok { + return asset, true + } + + return resolveInvolvedAsset(e.Right) + case accounts.Sub: + if asset, ok := resolveInvolvedAsset(e.Left); ok { + return asset, true + } + + return resolveInvolvedAsset(e.Right) + case accounts.Div: + if asset, ok := resolveInvolvedAsset(e.Left); ok { + return asset, true + } + + return resolveInvolvedAsset(e.Right) + case accounts.SubPrefix: + return resolveInvolvedAsset(e.Expr) + } + + return "", false +} diff --git a/internal/domain/processing/numscript/emulate_test.go b/internal/domain/processing/numscript/emulate_test.go index c652f9f12b..5afcf38999 100644 --- a/internal/domain/processing/numscript/emulate_test.go +++ b/internal/domain/processing/numscript/emulate_test.go @@ -164,6 +164,70 @@ func TestDiscoverNumscriptDependencies(t *testing.T) { require.True(t, hasBob, "should discover second destination") }) + t.Run("overdraft send discovers the unbounded-overdraft source (ledger#1500)", func(t *testing.T) { + t.Parallel() + + script := ` +#![feature("experimental-overdraft-function", "experimental-mid-script-function-call")] + send overdraft(@credit, USD/2) ( + source = @repay allowing unbounded overdraft + destination = @credit + ) + ` + + result, err := DiscoverNumscriptDependencies(testCache, script, nil, ledgerName) + require.NoError(t, err) + + // @repay is the source. overdraft() folds to zero against emulation's fake + // positive balance, so the send emits no posting and @repay is never seen + // by emulation alone — it is recovered only via the static involved-accounts + // union. + _, hasRepay := result.SourceVolumes[domain.VolumeKey{ + AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: "repay"}, + Asset: "USD/2", + }] + require.True(t, hasRepay, "should discover the unbounded-overdraft source @repay") + + // @credit is read by overdraft(), so it is captured via GetBalances too. + _, hasCredit := result.SourceVolumes[domain.VolumeKey{ + AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: "credit"}, + Asset: "USD/2", + }] + require.True(t, hasCredit, "should discover @credit") + }) + + t.Run("overdraft send inside arithmetic still discovers the unbounded-overdraft source", func(t *testing.T) { + t.Parallel() + + // Same class of bug as ledger#1500, but the amount wraps overdraft() in + // monetary arithmetic. Emulation folds overdraft() to zero against a fake + // positive balance and [USD/2 0] is trivially zero, so the whole `+` folds + // to zero and no posting is emitted — @repay is missed by emulation. The + // static-involved-accounts union catches this iff resolveInvolvedAsset can + // see through Add/Sub/Div/SubPrefix nodes to the embedded asset. Two + // overdrafts in one script would trip the determinism guard, so we combine + // overdraft() with a literal zero instead. + script := ` +#![feature("experimental-overdraft-function")] + vars { + monetary $due = overdraft(@credit, USD/2) + } + send [USD/2 0] + $due ( + source = @repay allowing unbounded overdraft + destination = @credit + ) + ` + + result, err := DiscoverNumscriptDependencies(testCache, script, nil, ledgerName) + require.NoError(t, err) + + _, hasRepay := result.SourceVolumes[domain.VolumeKey{ + AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: "repay"}, + Asset: "USD/2", + }] + require.True(t, hasRepay, "should discover @repay even when the amount wraps overdraft() in an Add node") + }) + t.Run("parse error returns error", func(t *testing.T) { t.Parallel() diff --git a/internal/domain/processing/numscript/errors.go b/internal/domain/processing/numscript/errors.go index 855d2f75fc..efdebf210b 100644 --- a/internal/domain/processing/numscript/errors.go +++ b/internal/domain/processing/numscript/errors.go @@ -6,6 +6,7 @@ import ( "fmt" numscriptlib "github.com/formancehq/numscript" + "github.com/formancehq/numscript/accounts" "github.com/formancehq/ledger/v3/internal/domain" ) @@ -82,3 +83,25 @@ func SafeRun(parsed numscriptlib.ParseResult, ctx context.Context, vars numscrip return } + +// safeGetInvolvedAccounts wraps ParseResult.GetInvolvedAccounts with a deferred +// recover. The numscript interpreter can still panic on open issues (see e.g. +// formancehq/numscript#153); admission callers treat both an error and a nil +// slice as "keep the emulation-only result", so we surface a panic through the +// same path rather than crashing the request. InvolvedMeta is intentionally +// dropped — the ledger admission layer only consumes InvolvedAccount. +func safeGetInvolvedAccounts(parsed numscriptlib.ParseResult, vars numscriptlib.VariablesMap) (involved []accounts.InvolvedAccount, err error) { + defer func() { + if r := recover(); r != nil { + involved = nil + err = fmt.Errorf("numscript panic: %v", r) + } + }() + + involved, _, ierr := parsed.GetInvolvedAccounts(vars) + if ierr != nil { + return nil, ierr + } + + return involved, nil +} diff --git a/tests/antithesis/workload/go.mod b/tests/antithesis/workload/go.mod index a4587b09ef..21d4efbf53 100644 --- a/tests/antithesis/workload/go.mod +++ b/tests/antithesis/workload/go.mod @@ -62,7 +62,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/formancehq/invariants v0.11.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getsentry/sentry-go v0.35.1 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -113,7 +113,7 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect diff --git a/tests/antithesis/workload/go.sum b/tests/antithesis/workload/go.sum index 5a07057474..d447aca82b 100644 --- a/tests/antithesis/workload/go.sum +++ b/tests/antithesis/workload/go.sum @@ -103,12 +103,12 @@ github.com/formancehq/go-libs/v5 v5.7.0 h1:2Z2S3vtOJr45tKpofhPqd0qKrPr6KB/LZZ+Ef github.com/formancehq/go-libs/v5 v5.7.0/go.mod h1:+jfCYWJ4Z10NGbhmbfon0hGoLe5pysbVQgePrg8M8W4= github.com/formancehq/invariants v0.11.0 h1:AHFBhK7U8Ak/qoJwgaNU+cAb9w61Ba7dwlcrJTkWBE4= github.com/formancehq/invariants v0.11.0/go.mod h1:ywzSexCUdhw2dC9Njiv0t4u2KfPVKj6SecFujPgmXno= -github.com/formancehq/numscript v0.0.24 h1:YBiDZ9zLVxTZVhtQ+taRcb6q2jArAvznWMfoWRVYGT0= -github.com/formancehq/numscript v0.0.24/go.mod h1:hC/VY5Vg04F5QkgdPPc6z/YsS/vh8V1qVJVa1VWnYMA= +github.com/formancehq/numscript v0.0.25-0.20260608064954-1fcd4893eafe h1:CL92PtU4vx7NGoTj6yohop09p8t5e4qpNWLSoPd24zM= +github.com/formancehq/numscript v0.0.25-0.20260608064954-1fcd4893eafe/go.mod h1:vQZOSwJItpYeCwUYNtV7bwu9naQoh2pdkkqs0ewkTiI= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ= -github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -289,8 +289,8 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/tests/e2e/business/numscript_test.go b/tests/e2e/business/numscript_test.go index 1cd5a2374d..f64d7f7fa1 100644 --- a/tests/e2e/business/numscript_test.go +++ b/tests/e2e/business/numscript_test.go @@ -588,4 +588,133 @@ send $amount ( Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) }) }) + + // Regression coverage for ledger#1500: overdraft() folds to zero against + // emulation's fake positive balance, so `send overdraft(...)` produced no + // posting during dependency discovery, and the unbounded-overdraft source's + // volume was never preloaded. At apply the real overdraft was non-zero, the + // posting materialised, and the write hit "read of undeclared key" + // (STORAGE_OPERATION_FAILED). The fix unions numscript's static + // GetInvolvedAccounts analysis into the preload set. These tests exercise + // the fix end-to-end through the gRPC apply path. + Context("When repaying an unbounded overdraft via overdraft() (regression #1500)", Ordered, func() { + var ledgerName = "numscript-overdraft-1500" + + BeforeAll(func() { + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateLedgerAction(ledgerName, nil))) + Expect(err).To(Succeed()) + }) + + It("Should repay via a mid-script overdraft() call from an unbounded-overdraft source", func() { + // Step 1: put @credit into overdraft by 100. This mirrors the reproduction + // in the issue. Emulation lets this through unmodified: @credit is an + // `allowing unbounded overdraft` source (interpreter never queries its + // balance), and @main only receives — so nothing here trips the discovery + // gap yet. + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateScriptTransactionAction(ledgerName, ` +send [USD/2 100] ( + source = @credit allowing unbounded overdraft + destination = @main +) +`, nil, nil))) + Expect(err).To(Succeed()) + + // Step 2: repay the overdraft. Before the fix this failed with + // STORAGE_OPERATION_FAILED / "source volume repay/USD/2" because @repay + // was never preloaded. With the static-involved-accounts union, @repay is + // preloaded and the transaction applies cleanly. + resp, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateScriptTransactionAction(ledgerName, ` +#![feature("experimental-overdraft-function", "experimental-mid-script-function-call")] +send overdraft(@credit, USD/2) ( + source = @repay allowing unbounded overdraft + destination = @credit +) +`, nil, nil))) + Expect(err).To(Succeed()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Logs).To(HaveLen(1)) + + // The repay posting materialises for exactly the overdrawn amount. + createdTx := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction() + Expect(createdTx).NotTo(BeNil()) + Expect(createdTx.Transaction.Postings).To(HaveLen(1)) + Expect(createdTx.Transaction.Postings[0].Source).To(Equal("repay")) + Expect(createdTx.Transaction.Postings[0].Destination).To(Equal("credit")) + Expect(createdTx.Transaction.Postings[0].Asset).To(Equal("USD/2")) + Expect(createdTx.Transaction.Postings[0].Amount.ToBigInt().String()).To(Equal("100")) + + // @credit is now flat (was -100, received +100 from @repay). + Eventually(func(g Gomega) { + credit, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "credit", + }) + g.Expect(err).To(Succeed()) + g.Expect(credit.Volumes).To(HaveKey("USD/2")) + g.Expect(credit.Volumes["USD/2"].Balance).To(Equal("0")) + }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) + + // @repay took the debt via its unbounded overdraft. + Eventually(func(g Gomega) { + repay, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "repay", + }) + g.Expect(err).To(Succeed()) + g.Expect(repay.Volumes).To(HaveKey("USD/2")) + g.Expect(repay.Volumes["USD/2"].Balance).To(Equal("-100")) + }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) + }) + + It("Should repay when overdraft() is bound through a vars block", func() { + // The issue notes: "Binding overdraft() through a vars block instead of a + // mid-script call fails identically." Same emulation gap, different call + // site — this locks the vars-block variant in as a regression. + _, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateScriptTransactionAction(ledgerName, ` +send [USD/2 50] ( + source = @credit_vars allowing unbounded overdraft + destination = @main +) +`, nil, nil))) + Expect(err).To(Succeed()) + + resp, err := sharedClient.Apply(sharedCtx, servicepb.UnsignedApplyRequest("", actions.CreateScriptTransactionAction(ledgerName, ` +#![feature("experimental-overdraft-function")] +vars { + monetary $due = overdraft(@credit_vars, USD/2) +} +send $due ( + source = @repay_vars allowing unbounded overdraft + destination = @credit_vars +) +`, nil, nil))) + Expect(err).To(Succeed()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Logs).To(HaveLen(1)) + + createdTx := resp.Logs[0].Payload.GetApply().Log.Data.GetCreatedTransaction() + Expect(createdTx.Transaction.Postings).To(HaveLen(1)) + Expect(createdTx.Transaction.Postings[0].Source).To(Equal("repay_vars")) + Expect(createdTx.Transaction.Postings[0].Destination).To(Equal("credit_vars")) + Expect(createdTx.Transaction.Postings[0].Amount.ToBigInt().String()).To(Equal("50")) + + Eventually(func(g Gomega) { + credit, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "credit_vars", + }) + g.Expect(err).To(Succeed()) + g.Expect(credit.Volumes["USD/2"].Balance).To(Equal("0")) + }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) + + Eventually(func(g Gomega) { + repay, err := sharedClient.GetAccount(sharedCtx, &servicepb.GetAccountRequest{ + Ledger: ledgerName, + Address: "repay_vars", + }) + g.Expect(err).To(Succeed()) + g.Expect(repay.Volumes["USD/2"].Balance).To(Equal("-50")) + }).Within(10 * time.Second).WithPolling(100 * time.Millisecond).Should(Succeed()) + }) + }) })