-
Notifications
You must be signed in to change notification settings - Fork 5
feat: virtual account #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
WalkthroughAdds a typed Account value with AccountAddress and VirtualAccount implementations, introduces a virtual() builtin gated by a feature flag/version, refactors interpreter send/receive to route through Sender with Account, and implements VirtualAccount credit/debit settlement. Updates funds stack, errors, tests, and adds extensive virtual-account script tests. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Script
participant Analyzer as Analyzer (builtins)
participant Interpreter
participant Feature as Flags/Version
participant V as VirtualAccount
note over Script,Interpreter: virtual() builtin resolution
Script->>Analyzer: Resolve var-origin: virtual()
Analyzer->>Feature: Check version>=0.0.20 + ExperimentalVirtualAccount
Feature-->>Analyzer: Allowed/Denied
alt Allowed
Analyzer-->>Interpreter: Var resolves to fn-call returning Account(VirtualAccount)
Interpreter->>V: NewVirtualAccount()
V-->>Interpreter: VirtualAccount instance
Interpreter-->>Script: Account{VirtualAccount}
else Denied
Analyzer-->>Script: Feature flag/version error
end
sequenceDiagram
autonumber
actor Script
participant I as Interpreter
participant FS as fundsStack
participant VA as VirtualAccount
participant Bal as Balances
note over Script,I: Send [ASSET X] from Source to Destination
Script->>I: Execute send(amount, asset, source, destination)
I->>I: Build Sender(s) (Account or VirtualAccount)
I->>FS: pushSender(Sender)
loop Pull senders for destination
I->>FS: Pull colored
FS-->>I: Sender
alt Destination is VirtualAccount
I->>VA: Receive(asset, sender)
VA-->>I: Postings (repay debits, credit remainder)
else Destination is AccountAddress
I->>I: pushReceiverAddress(name, sender)
I->>Bal: Update balances/postings
end
end
alt Source is VirtualAccount
I->>VA: Pull(asset, receiver)
VA-->>I: Postings (consume credits, record debits if overdraft)
else Source is AccountAddress
I->>Bal: Safe withdraw (by color)
Bal-->>I: Postings / errors
end
I-->>Script: Final postings or error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #69 +/- ##
==========================================
+ Coverage 71.32% 71.75% +0.43%
==========================================
Files 41 42 +1
Lines 4631 4872 +241
==========================================
+ Hits 3303 3496 +193
- Misses 1176 1222 +46
- Partials 152 154 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
dca1aab
to
72e72f1
Compare
afaa35a
to
55e8fda
Compare
finally some tests passing other passing tests fix edge cases fix edge cases cleaned up remove comment feat: better dbg I guess we made it? refactor remove unused code adde test and removed comment fix test renamed var moved function refactor simplify code simplify code added tests WIP test test: migrated tests test: migrated test test: migrated test test: migrate tests test: migrated tests test: migrated tests test: removed leftover test fix after rebase
55e8fda
to
7f832fa
Compare
8287590
to
44f703c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/interpreter/interpreter_error.go (1)
115-123
: Drop the hardcoded '@' and rely on Account’s own representation in the error messageNow that Account is a wrapper over multiple representations (e.g., AccountAddress, VirtualAccount), hardcoding '@' is incorrect for non-address accounts. Also, formatting with %s assumes a string; prefer using %v (or explicitly .String()).
Apply:
- return fmt.Sprintf("Cannot fetch negative balance from account @%s", e.Account) + return fmt.Sprintf("Cannot fetch negative balance from account %v", e.Account)If Account doesn’t implement fmt.Stringer, add a String() string method that renders addresses with a leading '@' and virtual accounts with their own canonical form.
🧹 Nitpick comments (32)
internal/interpreter/function_statements.go (1)
35-35
: Type erasure of meta to string.If downstream consumers rely on typed metadata (numbers, booleans), String() conversion loses type info. Consider storing the raw Value or a typed/JSON form instead.
internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num (1)
1-5
: Covers self-send noop; consider adding guard cases.Add a counterpart that tries self-send across different assets (should fail) and one with an overdraft bound of zero to ensure no unintended postings occur.
internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num (1)
1-11
: Edge-case companion tests would strengthen coverage.
- Add a variant where the second transfer only partially offsets the first (to keep a negative residual).
- Add assertions in the harness (if supported) to validate final balances of $v and @DesT.
internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num (1)
1-11
: Nice: positive flow with large bound. Add failing counterpart.Add a “bounded-overdraft-when-fails” case (e.g., bound below 90 when sending 100 after receiving 10) to verify strict enforcement.
internal/interpreter/interpreter_error.go (1)
39-47
: Optional: align other account-bearing error types to the Account wrapper for consistencyMetadataNotFound.Account still uses string, while NegativeBalanceError.Account is Account. Consider migrating others for a uniform API and future-proof messaging with virtual accounts.
I can batch-prepare a follow-up diff if you want.
Also applies to: 115-123
internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num (1)
1-14
: Verify portions syntax and intent aroundUSD/2 *
vsUSD/2 10
Both constructs look intentional; just confirm the grammar here matches current parser rules and semantics (half of all vs half with floor/min). If the distinction isn’t required, consider standardizing to one form in tests.
internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num (1)
8-14
: Clarify intent: duplicate$v
entries are intentional to test dedup.Consider adding a brief comment so future maintainers don’t “fix” the duplication.
internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num (1)
5-11
: Exercise the “merchant pays if needed” path with a concrete low-$amt case.As written, this relies on external input for $amt. Add a companion script (or fixture) with $amt = 30 so 10% -> 3 EUR and the second send must draw 2 EUR from @merchant, plus one with $amt = 50 (exact 5) to cover rounding edges.
internal/interpreter/args_parser_test.go (2)
50-51
: Make the expected Account literal explicit and keep expected-first order.Use the field name to avoid relying on struct field order and keep Equal(expected, actual) consistent.
- require.Equal(t, *big.NewInt(42), *a1) - require.Equal(t, Account{AccountAddress("user:001")}, *a2) + require.Equal(t, *big.NewInt(42), *a1) + require.Equal(t, Account{Repr: AccountAddress("user:001")}, *a2)Outside this hunk: prefer require.NoError(t, err) over require.Nil(t, err) at Lines 16 and 45 for clarity.
59-59
: Style: keep construction consistent across the file.You construct the same value as Account{Repr: AccountAddress("user:001")} above; keep that form everywhere in this test for uniformity.
internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num (2)
8-14
: Clarify “remaining kept” expectation with an assertion/golden.Intended outcome appears: 5 USD to @DesT, 5 USD left in $v. Please add/verify a golden so the next send moves exactly 5 USD to @other, confirming kept semantics.
16-19
: Final drain check.Consider another variant where the second step is max [USD 15] to ensure the third send is zero, exercising the empty-balance path.
internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num (1)
4-15
: Covers unbounded overdraft well; add a bounded-failure sibling.Add a test using “allowing overdraft up to [USD 150]” (or equivalent) to validate failure behavior and messages when limits are exceeded.
internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num (1)
13-19
: Oneof branch semantics: verify low-amount path is exercised.For “no commissions with low amount,” make sure $amt is low enough that the “max [EUR 5] to @DesT” branch is actually taken in CI to cover this scenario.
If helpful, add a brief comment stating the intended ranges for $amt to clarify the test’s purpose.
internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num (1)
11-16
: Fix minor grammar in comment.- // here's we're sending the credit we have from $v1 + // here we're sending the credit we have from $v1internal/interpreter/function_exprs.go (2)
22-27
: Runtime expects a real account address; static typing still allows any account.Switching to expectAccountAddress is correct at runtime, but analysis still types balance/meta/overdraft params as generic account. Users could pass virtual() and get a runtime error instead of a compile-time diagnostic.
Consider adding an analysis-time check that rejects virtual() where a real address is required, or introduce a distinct AccountAddress type in the analyzer to catch this earlier.
56-61
: Meta on virtual accounts.Same concern as above: meta() now requires a real address at runtime. Ensure the error clearly states “virtual accounts not allowed in meta()”.
If not already, specialize the expectAccountAddress error for virtual inputs.
internal/interpreter/batch_balances_query.go (2)
35-37
: Skip batching for virtual accounts—good.Type-asserting to AccountAddress avoids querying balances for virtual accounts. Consider a small helper (e.g., asAddress(acc Account) (string, bool)) to reduce repetition across sites.
102-105
: Same assertion pattern—consider helper to DRY.internal/analysis/check.go (1)
118-129
: Builtin registration and gating look correct; mind analyzer vs runtime mismatch.Zero-arity, returns account, gated at >= 0.0.20 + ExperimentalVirtualAccount—good. However, analyzer still allows virtual() anywhere TypeAccount is accepted, while runtime now rejects it for balance/meta/overdraft.
Add an analyzer diagnostic that forbids passing virtual() into those builtins, or model “account-address” as a distinct type for those parameters to fail fast at analysis time.
Also confirm tests set version >= 0.0.20 and enable the flag.
internal/interpreter/interpreter_test.go (3)
401-405
: Use keyed struct literal for stability when constructing AccountPositional struct literals are brittle across refactors. Prefer a keyed literal to avoid breakage if fields are reordered.
- Error: machine.NegativeBalanceError{ - Account: machine.Account{machine.AccountAddress("a")}, - Amount: *big.NewInt(-100), - }, + Error: machine.NegativeBalanceError{ + Account: machine.Account{Repr: machine.AccountAddress("a")}, + Amount: *big.NewInt(-100), + },
470-475
: Same: prefer keyed Account literal in TypeError.ValueKeeps tests resilient to internal struct changes.
- Error: machine.TypeError{ - Expected: "monetary", - Value: machine.Account{machine.AccountAddress("bad:type")}, - }, + Error: machine.TypeError{ + Expected: "monetary", + Value: machine.Account{Repr: machine.AccountAddress("bad:type")}, + },
611-615
: Same: prefer keyed Account literal in TypeError.Value- Error: machine.TypeError{ - Expected: "string", - Value: machine.Account{machine.AccountAddress("key_wrong_type")}, - }, + Error: machine.TypeError{ + Expected: "string", + Value: machine.Account{Repr: machine.AccountAddress("key_wrong_type")}, + },internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num (1)
1-16
: Comment/code mismatch: script currently sends full 10% to @fees, not “up to 5”
- The comment says “10%$amt (up to 5) to @fees”, but step 3 empties $fees_hold to @fees, resulting in total fees = 10%$amt when $amt > 50.
- If the intent is “min(10%*$amt, 5) goes to @fees and the rest to @DesT”, send the remainder of $fees_hold to $dest_hold (or @DesT), not @fees.
-// say that we need to send 10%*$amt (up to 5) to @fees; the rest to @dest +// send min(10% * $amt, 5) to @fees; send the rest to @dest @@ // now we empty the rest send [EUR *] ( source = $fees_hold - destination = @fees + destination = $dest_hold )If the goal was only “fail if $amt < 5”, updating the leading comment to reflect that would also resolve the mismatch.
Also applies to: 27-35
internal/interpreter/virtual_account_test.go (2)
26-33
: Consider using a test helper type instead of anonymous struct.The
Posting
type is defined as an anonymous struct. Consider creating a type alias or importing the actual Posting type from the interpreter package to improve maintainability.Add a type alias at the top of the file:
type Posting = interpreter.Posting
381-409
: Consider extracting the permutation helper to a test utilities package.The
permute
function is a generic utility that could be useful for other test files. While the GPT-generated comment is honest, consider moving this to a shared test utilities package for reuse. The implementation itself is correct.Would you like me to create a test utilities package with this and other potentially reusable test helpers?
internal/interpreter/virtual_account.go (1)
173-174
: TODO comment needs clarification or removal.The TODO comment on line 173 questions the overdraft implementation, but the code on line 174 appears to handle overdrafts correctly by checking if
remainingAmt
is positive. Either clarify the concern or remove the TODO if it's resolved.Would you like me to help investigate the overdraft implementation to determine if this TODO can be resolved?
internal/interpreter/interpreter.go (3)
332-356
: The function modifiestotalSent
in place which could be confusing.The function takes
totalSent *big.Int
and modifies it in place, then returns it. This dual approach (both modifying the parameter and returning it) could lead to confusion. The function namepushSender
also doesn't indicate that it calculates and returns a total.Consider either:
- Only modifying the parameter in place (void function)
- Only returning the new total without modifying the parameter
- Renaming the function to better indicate its dual purpose (e.g.,
pushSenderAndAccumulate
)
671-678
: Inefficient retrieval and processing of credits.The code first gets credits, then pulls from them. This two-step process could be optimized.
Consider combining the operations if the API allows:
- fs := account.getCredits(s.CurrentAsset) - pulledSenders := fs.PullColored(amount, *color) + // Pull colored credits directly from the virtual account + pulledSenders := account.PullColoredCredits(s.CurrentAsset, amount, *color)
689-694
: Complex logic for calculating additional send amount needs clarification.The calculation of
addionalSent
(note the typo: should beadditionalSent
) involves multiple conditions and could benefit from clearer variable naming and comments.- var addionalSent *big.Int + var additionalSent *big.Int if overdraft == nil { - addionalSent = new(big.Int).Set(missingAmt) + // Unlimited overdraft: send the full missing amount + additionalSent = new(big.Int).Set(missingAmt) } else { - // TODO check this is the correct number to eventually send - // TODO test overdraft - addionalSent = utils.MinBigInt(overdraft, missingAmt) + // Limited overdraft: send up to the overdraft limit + additionalSent = utils.MinBigInt(overdraft, missingAmt) } - s.pushSender(Sender{account, addionalSent, *color}, totalSent) + s.pushSender(Sender{account, additionalSent, *color}, totalSent)internal/interpreter/value.go (2)
12-20
: Interface method names could be more descriptive.The
account()
method name doesn't clearly indicate its purpose as a marker method. Consider using a more descriptive name likeisAccountValue()
or following Go's convention with a private method likeaccountValue()
.type AccountValue interface { - account() + isAccountValue() // Marker method for AccountValue implementations String() string } type AccountAddress string -func (AccountAddress) account() {} -func (VirtualAccount) account() {} +func (AccountAddress) isAccountValue() {} +func (VirtualAccount) isAccountValue() {}
166-178
: Function naming inconsistency and unclear separation of concerns.The function
expectAccountAddress
suggests it expects anAccountAddress
value directly, but it actually expects anAccount
and then extracts the address. This creates confusion about the function's purpose.Consider renaming for clarity:
-func expectAccountAddress(v Value, r parser.Range) (*string, InterpreterError) { +func expectAccountWithAddress(v Value, r parser.Range) (*string, InterpreterError) { acc, err := expectAccount(v, r) if err != nil { return nil, err } switch acc := acc.Repr.(type) { case AccountAddress: s := string(acc) return &s, nil default: - return nil, TypeError{Expected: analysis.TypeAccount, Value: v, Range: r} + // More specific error message + return nil, TypeError{Expected: "account with address", Value: v, Range: r} } }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
⛔ Files ignored due to path filters (18)
internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num.specs.json
is excluded by!**/*.json
internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num.specs.json
is excluded by!**/*.json
📒 Files selected for processing (33)
internal/analysis/check.go
(2 hunks)internal/flags/flags.go
(2 hunks)internal/interpreter/args_parser_test.go
(2 hunks)internal/interpreter/batch_balances_query.go
(3 hunks)internal/interpreter/evaluate_expr.go
(1 hunks)internal/interpreter/function_exprs.go
(5 hunks)internal/interpreter/function_statements.go
(1 hunks)internal/interpreter/funds_stack.go
(3 hunks)internal/interpreter/funds_stack_test.go
(3 hunks)internal/interpreter/interpreter.go
(11 hunks)internal/interpreter/interpreter_error.go
(1 hunks)internal/interpreter/interpreter_test.go
(3 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/account-kept.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/bounded-overdraft-when-does-not-fail.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-fail-if-not-enough.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-left-negative.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending-send-all.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/repay-self.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/test-send-self-is-noop.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num
(1 hunks)internal/interpreter/testdata/script-tests/experimental/virtual-account/wrong-currency.num
(1 hunks)internal/interpreter/value.go
(4 hunks)internal/interpreter/virtual_account.go
(1 hunks)internal/interpreter/virtual_account_test.go
(1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-07-16T14:51:51.052Z
Learnt from: ascandone
PR: formancehq/numscript#70
File: internal/interpreter/accounts_metadata.go:13-24
Timestamp: 2025-07-16T14:51:51.052Z
Learning: The DeepClone method in internal/interpreter/accounts_metadata.go works correctly despite using MapGetOrPutDefault in what initially appears to be a non-assignment context. The fetchAccountMetadata method returns a reference to the map stored in the cloned structure, and MapGetOrPutDefault modifies it in-place, so the cloned values persist correctly.
Applied to files:
internal/interpreter/function_statements.go
📚 Learning: 2024-12-05T11:42:58.472Z
Learnt from: ascandone
PR: formancehq/numscript#27
File: internal/interpreter/interpreter.go:667-668
Timestamp: 2024-12-05T11:42:58.472Z
Learning: In Go test files within the `internal/interpreter` package (e.g., `reconciler_test.go` and `interpreter_test.go`), it's acceptable to use hardcoded `"<kept>"` strings in test data and comments, and they do not need to be replaced with the `KEPT_ADDR` constant.
Applied to files:
internal/interpreter/args_parser_test.go
internal/interpreter/interpreter_test.go
🧬 Code graph analysis (13)
internal/interpreter/args_parser_test.go (3)
internal/interpreter/value.go (6)
Account
(30-30)Account
(38-38)AccountAddress
(17-17)AccountAddress
(19-19)Value
(22-25)NewMonetaryInt
(253-256)internal/interpreter/args_parser.go (1)
NewArgsParser
(11-15)numscript.go (1)
Value
(72-72)
internal/interpreter/funds_stack.go (1)
internal/interpreter/value.go (3)
Account
(30-30)Account
(38-38)AccountValue
(12-15)
internal/analysis/check.go (3)
internal/analysis/version_check.go (1)
VersionClause
(54-57)internal/parser/version.go (2)
Version
(9-12)NewVersionInterpreter
(28-34)internal/flags/flags.go (2)
FeatureFlag
(3-3)ExperimentalVirtualAccount
(13-13)
internal/interpreter/interpreter_error.go (1)
internal/interpreter/value.go (2)
Account
(30-30)Account
(38-38)
internal/interpreter/virtual_account_test.go (4)
internal/interpreter/virtual_account.go (2)
NewVirtualAccount
(31-36)VirtualAccount
(10-14)internal/interpreter/funds_stack.go (1)
Sender
(7-11)internal/interpreter/value.go (7)
Account
(30-30)Account
(38-38)AccountAddress
(17-17)AccountAddress
(19-19)Asset
(28-28)Asset
(42-42)VirtualAccount
(20-20)internal/interpreter/interpreter.go (1)
Posting
(80-85)
internal/interpreter/virtual_account.go (4)
internal/interpreter/value.go (10)
VirtualAccount
(20-20)String
(27-27)String
(37-37)AccountValue
(12-15)AccountAddress
(17-17)AccountAddress
(19-19)Asset
(28-28)Asset
(42-42)Account
(30-30)Account
(38-38)internal/utils/utils.go (1)
MapGetOrPutDefault
(50-58)internal/interpreter/funds_stack.go (1)
Sender
(7-11)internal/interpreter/interpreter.go (1)
Posting
(80-85)
internal/interpreter/funds_stack_test.go (2)
internal/interpreter/value.go (4)
Account
(30-30)Account
(38-38)AccountAddress
(17-17)AccountAddress
(19-19)internal/interpreter/funds_stack.go (1)
Sender
(7-11)
internal/interpreter/batch_balances_query.go (1)
internal/interpreter/value.go (2)
AccountAddress
(17-17)AccountAddress
(19-19)
internal/interpreter/interpreter_test.go (2)
internal/interpreter/value.go (5)
Account
(30-30)Account
(38-38)AccountAddress
(17-17)AccountAddress
(19-19)Value
(22-25)numscript.go (1)
Value
(72-72)
internal/interpreter/function_exprs.go (4)
internal/interpreter/value.go (5)
Account
(30-30)Account
(38-38)AccountAddress
(17-17)AccountAddress
(19-19)Value
(22-25)internal/interpreter/interpreter.go (1)
InterpreterError
(73-76)internal/flags/flags.go (1)
ExperimentalVirtualAccount
(13-13)internal/interpreter/virtual_account.go (1)
NewVirtualAccount
(31-36)
internal/interpreter/interpreter.go (7)
internal/analysis/check.go (1)
FnVarOriginVirtual
(64-64)internal/interpreter/value.go (9)
Account
(30-30)Account
(38-38)VirtualAccount
(20-20)AccountAddress
(17-17)AccountAddress
(19-19)Asset
(28-28)Asset
(42-42)String
(27-27)String
(37-37)internal/interpreter/virtual_account.go (1)
VirtualAccount
(10-14)internal/interpreter/funds_stack.go (1)
Sender
(7-11)internal/parser/ast.go (4)
Destination
(189-192)DestinationAccount
(194-194)DestinationAccount
(200-202)ValueExpr
(8-11)internal/interpreter/interpreter_error.go (1)
InvalidUnboundedInSendAll
(151-154)internal/utils/utils.go (2)
NonExhaustiveMatchPanic
(8-10)MinBigInt
(12-22)
internal/interpreter/value.go (5)
internal/interpreter/virtual_account.go (1)
VirtualAccount
(10-14)internal/interpreter/interpreter.go (1)
InterpreterError
(73-76)internal/interpreter/interpreter_error.go (2)
InvalidAccountName
(215-218)TypeError
(49-53)internal/parser/range.go (1)
Range
(13-16)internal/analysis/check.go (1)
TypeAccount
(14-14)
internal/interpreter/evaluate_expr.go (1)
internal/interpreter/value.go (2)
Account
(30-30)Account
(38-38)
🔇 Additional comments (36)
internal/interpreter/function_statements.go (2)
23-23
: Good call: require a concrete account address for metadata.Using expectAccountAddress ensures metadata can’t be set on virtual accounts or non-concrete values. This aligns with the virtual-account model.
31-36
: illationinternal/interpreter/evaluate_expr.go (2)
39-41
: Ensure NewAccountAddress returns the Account wrapper.Given castToString now matches Account, returning Account from NewAccountAddress avoids subtle mismatches in downstream type switches.
218-221
: Verify castToString handles raw AccountAddress
Confirm that in evaluate_expr.go the AST’s account‐literal branch always wraps addresses via NewAccountAddress (returning Account) and that castToString never sees a bare AccountAddress value. If direct AccountAddress values can still flow into castToString, add:case Account: return v.String(), nil + case AccountAddress: + return v.String(), nilinternal/interpreter/testdata/script-tests/experimental/virtual-account/transitive.num (1)
1-16
: LGTM: clear transitive flow across two virtual accountsSequential sends world→v1→v2→dest exercise the transitive path well. Good coverage for balance propagation.
internal/interpreter/testdata/script-tests/experimental/virtual-account/prevent-double-spending.num (1)
1-14
: LGTM: good negative test for double-spending from the same virtual sourceListing $v twice in the source set should be rejected. Ensure the harness asserts the specific error surfaced by the interpreter for clarity.
internal/interpreter/testdata/script-tests/experimental/virtual-account/account-create.num (1)
1-11
: LGTM: basic fund-and-spend with a virtual accountCovers the happy path world→$v then $v→dest. Nice baseline.
internal/interpreter/testdata/script-tests/experimental/virtual-account/half-using-virtual.num (1)
6-6
: No changes needed: grammar supportsallowing unbounded overdraft
inside composite sources. Tests acrossinternal/interpreter/testdata/script-tests
already use this modifier within braces, so the existing line is valid.internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft.num (2)
5-8
: LGTM: unbounded overdraft path covered.This exercises negative balance then later top-up; matches the described intent.
4-4
: Comment syntax validated.//
comments are consistently used in .num test files and supported by Numscript.internal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num (2)
8-11
: Good negative case for bounded overdraft.Covers “credit 10 then attempt 100 with limit 1”; this should fail.
8-11
: Annotate expected overdraft failure in the test script.
Ininternal/interpreter/testdata/script-tests/experimental/virtual-account/overdraft-when-fails.num
, add a test-harness directive (e.g. a comment like# expect-error: NegativeBalance: overdraft exceeded
) so the framework records and asserts the expected failure.internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-merchant-pays-fees-if-needed.num (1)
12-22
: Confirm source stacking order ($fees_hold drained before @merchant).The intent seems to be “use $fees_hold, then fallback to @merchant.” Please verify interpreter behavior preserves this order and add a golden asserting which postings are produced when $fees_hold < 5.
internal/interpreter/args_parser_test.go (1)
39-40
: Good: tests now pass Account wrapper, not bare AccountAddress.internal/interpreter/testdata/script-tests/experimental/virtual-account/creditors-stack.num (1)
16-19
: Verify creditor ordering on later inflow.Please add/confirm goldens showing how the incoming 150 is applied against prior debits (ordering rules), so regressions are caught.
internal/interpreter/testdata/script-tests/experimental/virtual-account/min-constraint-no-commissions-with-low-amount.num (2)
1-5
: Virtual accounts declared correctly; ensure flag/version are enabled in tests.This script depends on ExperimentalVirtualAccount and version >= 0.0.20. Please confirm the test harness sets the interpreter version and enables the flag, otherwise the run will fail at parse/exec time.
20-27
: LGTM: final drains from holds mirror the intended flow.Transfers from $fees_hold to @fees and from $dest_hold to @DesT look consistent with the multi-hop design.
internal/interpreter/testdata/script-tests/experimental/virtual-account/send-credit-around.num (1)
18-22
: Verify “no postings” assertion is covered.Please confirm the expected-results fixture asserts that this third send yields zero postings.
internal/interpreter/function_exprs.go (3)
89-96
: Balance on virtual accounts.Identical static/runtime mismatch note as for overdraft/meta. Otherwise, change is correct.
Please verify existing tests cover passing virtual() to balance/meta/overdraft and produce the intended error.
103-107
: NegativeBalanceError.Account wrapping.Using Account{AccountAddress(*account)} matches the new Account value model. Confirm all consumers of NegativeBalanceError now expect Account (not raw address).
159-167
: virtual() builtin gated correctly.Feature-flag check and return shape look good. No further issues.
internal/interpreter/batch_balances_query.go (1)
123-126
: Overdraft source batching respects virtual accounts.LGTM. Skips when overdraft is unbounded and when source is virtual.
internal/analysis/check.go (1)
64-65
: New builtin symbol name is clear and consistent.FnVarOriginVirtual constant naming aligns with existing conventions.
internal/interpreter/funds_stack.go (3)
8-10
: Move to AccountValue looks good; confirm equality semantics across implementationsStoring Account as AccountValue is aligned with the new abstraction. Please confirm all current AccountValue implementations are intended to be compared by value (addresses) or identity (virtual accounts) consistently across the stack logic.
83-91
: Merge result construction is correct; amount addition is safeThe aggregated Sender keeps Account and Color and sums Amounts as expected.
155-168
: Split/return path is correct; leftover is pushed back with preserved Account/ColorLogic for “more than enough” and exact split looks good and preserves invariants.
internal/interpreter/funds_stack_test.go (1)
12-13
: LGTM! Clean migration to AccountAddress type.The refactoring from
Name
toAccount: AccountAddress(...)
improves type safety and aligns with the new account abstraction model.Also applies to: 17-18
internal/interpreter/virtual_account_test.go (2)
1-10
: Test file structure looks good.Using a separate test package (
interpreter_test
) promotes black-box testing and ensures the API is tested from a consumer perspective.
314-379
: Excellent test coverage for commutativity.The commutative order test exhaustively validates that the order of operations doesn't affect the final outcome, which is crucial for a financial system. The test properly verifies all 24 permutations of the 4 operations.
internal/interpreter/virtual_account.go (3)
38-50
: Good use of helper functions for lazy initialization.The
getCredits
andgetDebits
helper methods properly encapsulate the lazy initialization pattern using the utility function. This ensures consistent behavior across the codebase.
52-69
: Clear and well-documented public API.The
Receive
method has excellent documentation explaining the FIFO policy and debt repayment mechanism. The implementation correctly handles the debt-first repayment logic.
114-140
: Good separation of concerns in repayWithSender.The helper function properly encapsulates the debt repayment logic and correctly manages the remaining amount calculation.
internal/interpreter/interpreter.go (4)
197-198
: Remove redundant bounds‐check suggestion –virtual(s)
already validates the ExperimentalVirtualAccount feature flag and returns an error on failure, so no additional validation is needed.Likely an incorrect or invalid review comment.
224-230
: SettingDbg
field directly mutates the struct - consider immutability.The code creates a
VirtualAccount
and then mutates itsDbg
field. This breaks immutability and could lead to unexpected behavior if the sameVirtualAccount
is used elsewhere.Consider creating a new
VirtualAccount
with the debug name instead of mutating:- if acc, ok := value.(Account); ok { - if vacc, ok := acc.Repr.(VirtualAccount); ok { - vacc.Dbg = varsDecl.Name.Name - value = Account{vacc} - } - } + if acc, ok := value.(Account); ok { + if vacc, ok := acc.Repr.(VirtualAccount); ok { + // Create a new VirtualAccount with the debug name + newVacc := VirtualAccount{ + Dbg: varsDecl.Name.Name, + credits: vacc.credits, + debits: vacc.debits, + } + value = Account{newVacc} + } + }Likely an incorrect or invalid review comment.
543-557
: Missing nil check foraccount.PullCredits
return value.The code calls
account.PullCredits(s.CurrentAsset)
and iterates over the result, but doesn't check if it returns nil or handle potential errors.Add nil check for safety:
case VirtualAccount: totalSent := big.NewInt(0) senders := account.PullCredits(s.CurrentAsset) + if senders == nil { + return totalSent, nil + } for _, sender := range senders { s.pushSender(sender, totalSent) }Likely an incorrect or invalid review comment.
440-444
: No extra error handling needed for account address conversion.evaluateExprAs(..., expectAccountAddress)
already enforces and returns an error on invalid types.Likely an incorrect or invalid review comment.
ExperimentalVirtualAccount FeatureFlag = "experimental-virtual-account" | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Flag added and wired into AllFlags — LGTM. Also verify usage coverage.
- OK to add ExperimentalVirtualAccount and include it in AllFlags.
- Optional: align naming with peers (e.g., ExperimentalVirtualAccountFeatureFlag) or type AllFlags as []FeatureFlag for readability (alias to string anyway).
Run to confirm the flag is actually gating features and tests:
Also applies to: 24-25
🏁 Script executed:
#!/bin/bash
rg -nC2 '\bExperimentalVirtualAccount\b'
Length of output: 1295
Add unit tests for ExperimentalVirtualAccount gating
- Usage confirmed in internal/interpreter/function_exprs.go (checkFeatureFlag at line 162) and internal/analysis/check.go (FeatureFlag at line 125).
- No
_test.go
files referenceExperimentalVirtualAccount
; add tests covering both enabled and disabled scenarios to ensure the flag actually gates behavior.
🤖 Prompt for AI Agents
In internal/flags/flags.go around lines 13-14, there are no unit tests for the
ExperimentalVirtualAccount flag; add tests that verify behavior when the flag is
enabled and disabled. Create a new _test.go in the same package that (1) sets
ExperimentalVirtualAccount on, exercises the code paths that depend on it (e.g.,
call the feature-checking helper used by internal/interpreter/function_exprs.go
and any API/analysis functions referenced in internal/analysis/check.go) and
asserts the gated behavior is allowed, and (2) sets the flag off and asserts the
gated behavior is denied; ensure tests clean up/reset global flag state (use
defer to restore original value) and include clear assertions for both
scenarios.
{AccountAddress("src"), big.NewInt(10), "X"}, | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Fix inconsistent Sender struct initialization.
Line 126 uses the shorthand struct literal syntax while the rest of the file uses explicit field names. This inconsistency makes the code harder to maintain.
Apply this diff to fix the inconsistency:
- {AccountAddress("src"), big.NewInt(10), "X"},
+ {Account: AccountAddress("src"), Amount: big.NewInt(10), Color: "X"},
Also applies to: 131-131, 136-136
🤖 Prompt for AI Agents
In internal/interpreter/funds_stack_test.go around lines 126-127 (and also at
131 and 136), the Sender struct is initialized using a shorthand positional
literal ({AccountAddress("src"), big.NewInt(10), "X"}) while the rest of the
file uses explicit field names; update these initializations to use explicit
field names (e.g., Sender{Address: ..., Amount: ..., Currency: ...}) to match
the file's style and improve maintainability.
{AccountAddress("s1"), big.NewInt(1), "c1"}, | ||
{AccountAddress("s2"), big.NewInt(1), "c2"}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Fix inconsistent Sender struct initialization in multiple locations.
Lines 166-167, 179, and 187 use shorthand struct literal syntax inconsistently with the rest of the file.
Apply this diff to maintain consistency:
- {AccountAddress("s1"), big.NewInt(1), "c1"},
- {AccountAddress("s2"), big.NewInt(1), "c2"},
+ {Account: AccountAddress("s1"), Amount: big.NewInt(1), Color: "c1"},
+ {Account: AccountAddress("s2"), Amount: big.NewInt(1), Color: "c2"},
- {AccountAddress("s1"), big.NewInt(10), ""},
+ {Account: AccountAddress("s1"), Amount: big.NewInt(10), Color: ""},
- {AccountAddress("s1"), big.NewInt(10), ""},
+ {Account: AccountAddress("s1"), Amount: big.NewInt(10), Color: ""},
Also applies to: 179-180, 187-187
🤖 Prompt for AI Agents
internal/interpreter/funds_stack_test.go around lines 166-167, 179-180 and 187:
the tests use shorthand composite literals like {AccountAddress("s1"),
big.NewInt(1), "c1"} which is inconsistent with the rest of the file; update
each occurrence to use explicit field names matching the Sender struct (e.g.
Sender{Address: AccountAddress("s1"), Amount: big.NewInt(1), Currency: "c1"}) so
the initializers are consistent and resilient to field reordering.
if first.Account != second.Account || first.Color != second.Color { | ||
return | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Avoid interface equality that can panic for non-comparable AccountValue types; make equality explicit
Comparing interface values with “!=” panics if the dynamic type is not comparable. Also, equality semantics may differ by implementation (string address vs virtual account identity). Use an explicit comparer.
- if first.Account != second.Account || first.Color != second.Color {
+ if !accountsEqual(first.Account, second.Account) || first.Color != second.Color {
return
}
Add helper (outside the shown range) and import reflect:
import "reflect"
// accountsEqual defines equality semantics for AccountValue.
// - AccountAddress: value equality
// - pointer-backed implementations (e.g., virtual accounts): pointer identity
// - fallback: DeepEqual (safe for non-comparable structs)
func accountsEqual(a, b AccountValue) bool {
if a == nil || b == nil {
return a == b
}
ta, tb := reflect.TypeOf(a), reflect.TypeOf(b)
if ta != tb {
return false
}
va, vb := reflect.ValueOf(a), reflect.ValueOf(b)
switch ta.Kind() {
case reflect.String, reflect.Int, reflect.Int64, reflect.Uint, reflect.Uint64, reflect.Bool:
return a == b
case reflect.Ptr, reflect.UnsafePointer:
return va.Pointer() == vb.Pointer()
default:
return reflect.DeepEqual(a, b)
}
}
🏁 Script executed:
#!/bin/bash
set -eu
# Locate the definition of AccountValue
rg -nP 'type\s+AccountValue' -C3 -g '*.go'
# Preview imports in internal/interpreter/funds_stack.go
sed -n '1,50p' internal/interpreter/funds_stack.go
# Show context around lines 79-81
sed -n '60,100p' internal/interpreter/funds_stack.go
# Check whether reflect is already imported
rg -n 'reflect' internal/interpreter/funds_stack.go
Length of output: 2974
🏁 Script executed:
#!/bin/bash
set -eu
# Show AccountValue definition and its implementers in value.go
sed -n '1,200p' internal/interpreter/value.go
# Locate all types implementing the account() method
rg -nP '\.account\(' -C3 -g '*.go'
Length of output: 4506
🏁 Script executed:
#!/bin/bash
set -eu
# Locate the definition of VirtualAccount
rg -nP 'type\s+VirtualAccount\b' -C3 -g '*.go'
# If found, show its full definition (context +/-10 lines)
FILE_AND_LINE=$(rg -nP 'type\s+VirtualAccount\b' -n -g '*.go' | head -n1)
if [ -n "$FILE_AND_LINE" ]; then
FILE=$(echo "$FILE_AND_LINE" | cut -d: -f1)
LINE=$(echo "$FILE_AND_LINE" | cut -d: -f2)
start=$(( LINE > 10 ? LINE - 10 : 1 ))
sed -n "${start},$((LINE+10))p" "$FILE"
fi
Length of output: 1415
Replace interface equality on AccountValue with explicit helper
Dynamic types like VirtualAccount contain non-comparable maps, so first.Account != second.Account
will panic at runtime. Change the check to:
if !accountsEqual(first.Account, second.Account) || first.Color != second.Color {
return
}
Import "reflect"
and add an accountsEqual(a, b AccountValue) bool
helper that handles nil, enforces same type, compares pointers for pointer-backed types, and falls back to reflect.DeepEqual
.
🤖 Prompt for AI Agents
internal/interpreter/funds_stack.go around lines 79 to 81: the current equality
check uses interface equality (first.Account != second.Account) which can panic
for dynamic types containing non-comparable fields; replace that condition with
a call to a new helper accountsEqual(first.Account, second.Account) and keep the
Color check; add import "reflect" and implement accountsEqual(a, b AccountValue)
bool that returns true for both nil, ensures types match, for pointer-backed
concrete types compares pointer addresses when possible, and otherwise falls
back to reflect.DeepEqual to determine equality.
case VirtualAccount: | ||
// Here we have a debt from a virtual acc. | ||
// we don't want to emit that as a posting (but TODO check how does it interact with kept) | ||
senderAccountAddress.Pull(st.CurrentAsset, Sender{ | ||
AccountAddress(name), | ||
sender.Amount, | ||
sender.Color, | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Virtual account debt handling lacks validation.
When handling virtual account debts, the code calls Pull
on the sender but doesn't verify if the operation succeeds or if there are sufficient funds.
Add validation to ensure the Pull operation is valid:
case VirtualAccount:
// Here we have a debt from a virtual acc.
// we don't want to emit that as a posting (but TODO check how does it interact with kept)
+ // Validate that the virtual account can handle this debt
senderAccountAddress.Pull(st.CurrentAsset, Sender{
AccountAddress(name),
sender.Amount,
sender.Color,
})
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In internal/interpreter/interpreter.go around lines 395 to 402, the
VirtualAccount branch calls senderAccountAddress.Pull(...) without validating
the result or checking funds; update this block to check Pull's return (or
pre-check balance) and handle failures: verify Pull succeeded (or that sender
has sufficient amount for st.CurrentAsset), and on failure return or propagate
an appropriate error (or log and mark the transaction invalid) instead of
proceeding silently; ensure any error path cleans up state consistently and
preserves existing flow for successful pulls.
func NewAccountAddress(src string) (Account, InterpreterError) { | ||
if !validateAddress(src) { | ||
return AccountAddress(""), InvalidAccountName{Name: src} | ||
return Account{AccountAddress("")}, InvalidAccountName{Name: src} | ||
} | ||
return AccountAddress(src), nil | ||
return Account{AccountAddress(src)}, nil | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error handling returns invalid state.
When validation fails, the function returns Account{AccountAddress("")}
with an error. This creates an invalid Account
object that could cause issues if the error is ignored.
Return a zero value or nil-like representation instead:
func NewAccountAddress(src string) (Account, InterpreterError) {
if !validateAddress(src) {
- return Account{AccountAddress("")}, InvalidAccountName{Name: src}
+ return Account{}, InvalidAccountName{Name: src}
}
return Account{AccountAddress(src)}, nil
}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
func NewAccountAddress(src string) (Account, InterpreterError) { | |
if !validateAddress(src) { | |
return AccountAddress(""), InvalidAccountName{Name: src} | |
return Account{AccountAddress("")}, InvalidAccountName{Name: src} | |
} | |
return AccountAddress(src), nil | |
return Account{AccountAddress(src)}, nil | |
} | |
func NewAccountAddress(src string) (Account, InterpreterError) { | |
if !validateAddress(src) { | |
return Account{}, InvalidAccountName{Name: src} | |
} | |
return Account{AccountAddress(src)}, nil | |
} |
🤖 Prompt for AI Agents
internal/interpreter/value.go around lines 44-49: the function currently returns
a partially populated Account{AccountAddress("")} when validation fails which
creates an invalid object; change the failure return to the zero-value Account{}
(i.e., return Account{}, InvalidAccountName{Name: src}) so callers can't
accidentally use an invalid Account when they ignore the error.
v0.Dbg = "v0" | ||
|
||
v1 := interpreter.NewVirtualAccount() | ||
v0.Dbg = "v1" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix incorrect debug field assignment.
Line 139 incorrectly assigns to v0.Dbg
instead of v1.Dbg
, which defeats the purpose of labeling the virtual accounts for debugging.
Apply this diff to fix the assignment:
- v0.Dbg = "v1"
+ v1.Dbg = "v1"
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
v0.Dbg = "v1" | |
v1.Dbg = "v1" |
🤖 Prompt for AI Agents
In internal/interpreter/virtual_account_test.go around line 139, the test
incorrectly sets the debug label on v0 (v0.Dbg = "v1") instead of v1; change the
assignment to set v1.Dbg = "v1" so each virtual account has its intended debug
label (verify v0.Dbg remains "v0" and update only the single line to assign the
correct variable).
func (v VirtualAccount) WithDbg(dbg string) VirtualAccount { | ||
v.Dbg = dbg | ||
return v | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using pointer receiver for WithDbg method.
The WithDbg
method uses a value receiver and returns a copy of the VirtualAccount. Since VirtualAccount contains maps (reference types), this creates a shallow copy where the maps are shared between instances. This could lead to unexpected behavior.
Apply this diff to use a pointer receiver:
-func (v VirtualAccount) WithDbg(dbg string) VirtualAccount {
- v.Dbg = dbg
- return v
+func (v *VirtualAccount) WithDbg(dbg string) *VirtualAccount {
+ v.Dbg = dbg
+ return v
}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
func (v VirtualAccount) WithDbg(dbg string) VirtualAccount { | |
v.Dbg = dbg | |
return v | |
} | |
func (v *VirtualAccount) WithDbg(dbg string) *VirtualAccount { | |
v.Dbg = dbg | |
return v | |
} |
🤖 Prompt for AI Agents
internal/interpreter/virtual_account.go around lines 16 to 19: change WithDbg to
use a pointer receiver so it mutates the existing VirtualAccount instead of
returning a shallow-copied value; update the signature to func (v
*VirtualAccount) WithDbg(dbg string) *VirtualAccount { v.Dbg = dbg; return v }
and then update any call sites that expect a value return to accept the pointer
(or explicitly dereference) to preserve chaining while avoiding shared-map copy
issues.
}} | ||
case VirtualAccount: | ||
// should be unreachable | ||
panic("Unhandled: send from addr to virtual account") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Consider returning errors instead of panicking.
The send
function panics in two unreachable cases. While these cases should theoretically never occur, panicking in production code can crash the entire application. Consider returning an error instead.
Apply this diff to handle errors gracefully:
func send(
source AccountValue,
destination AccountValue,
amount *big.Int,
asset string,
color string,
-) []Posting {
+) ([]Posting, error) {
switch source := source.(type) {
case AccountAddress:
switch destination := destination.(type) {
case AccountAddress:
- return []Posting{{
+ return []Posting{{
Source: string(source),
Destination: string(destination),
Amount: amount,
Asset: coloredAsset(asset, &color),
- }}
+ }}, nil
case VirtualAccount:
- panic("Unhandled: send from addr to virtual account")
+ return nil, fmt.Errorf("unhandled: send from addr to virtual account")
}
case VirtualAccount:
switch dest := destination.(type) {
case AccountAddress:
- return source.Pull(asset, Sender{
+ postings := source.Pull(asset, Sender{
Account: dest,
Amount: amount,
Color: color,
})
+ return postings, nil
case VirtualAccount:
- panic("Unhandled: send from virtual account to virtual account")
+ return nil, fmt.Errorf("unhandled: send from virtual account to virtual account")
}
}
- panic("non exhaustive match")
+ return nil, fmt.Errorf("non exhaustive match in send function")
}
Note: This change would require updating callers to handle the error.
Also applies to: 106-106
🤖 Prompt for AI Agents
In internal/interpreter/virtual_account.go at lines 91 and 106, replace the
panics ("Unhandled: send from addr to virtual account") with returned errors:
change the send function signature to return an error (or add an error return if
it already returns values), create and return a descriptive error (e.g.,
fmt.Errorf or a package-level sentinel error) at those two branches, and update
all callers to handle and propagate or log the error appropriately; ensure tests
and call sites are adjusted to handle the new error return.
pulled := credits.PullColored(receiver.Amount, receiver.Color) | ||
|
||
remainingAmt := new(big.Int).Set(receiver.Amount) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential bug: remainingAmt is never decremented in Pull method.
The remainingAmt
variable is initialized to receiver.Amount
but is never decremented when postings are created. This means the overdraft logic will always trigger if any amount is requested, even if sufficient credits are available.
Apply this diff to fix the bug:
for _, pulledSender := range pulled {
newPostings := send(
pulledSender.Account,
receiver.Account,
pulledSender.Amount,
asset,
receiver.Color,
)
postings = append(postings, newPostings...)
+ remainingAmt.Sub(remainingAmt, pulledSender.Amount)
}
Also applies to: 173-183
🤖 Prompt for AI Agents
In internal/interpreter/virtual_account.go around line 159 (and similarly for
the block at 173-183), remainingAmt is initialized to receiver.Amount but never
decremented while creating postings, so overdraft logic always triggers; fix by
decrementing remainingAmt each time you allocate funds to a posting: inside the
loop over available credits compute allocated = min(credit.Amount,
remainingAmt), create a posting for allocated, subtract allocated from
remainingAmt, and stop iterating when remainingAmt == 0; after the loop, if
remainingAmt > 0 then trigger the overdraft behavior (or return an error) as
originally intended.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/interpreter/interpreter.go (1)
524-553
: Honor requested asset color in VirtualAccount send-allIn the VirtualAccount branch of sendAll (internal/interpreter/interpreter.go around line 547), we currently call
senders := account.PullCredits(s.CurrentAsset)which unconditionally drains all colors. Instead, when
color
is non-empty we must filter by that color—e.g.:senders := s.getCredits(s.CurrentAsset).PullColored(*color)and only fall back to
PullAll()
ifcolor == ""
.
♻️ Duplicate comments (1)
internal/interpreter/interpreter.go (1)
395-403
: Validate Pull or pre-check availability (repeat from prior review).Pull on a VirtualAccount is unchecked. Validate its result or pre-check available credits/debt policy; propagate an error on failure instead of proceeding silently.
🧹 Nitpick comments (3)
internal/interpreter/function_exprs.go (1)
22-24
: Overdraft should not accept virtual accounts (clarify TODO or implement).You switched to expectAccountAddress, which effectively excludes virtual accounts. If that’s the intended semantics, drop the TODO. If overdraft must support virtual accounts later, ensure getBalance(Account) handles VirtualAccount correctly (see my getBalance fix in interpreter.go).
internal/interpreter/interpreter.go (2)
225-231
: Prefer named field init for clarity; copy semantics are fine.Setting vacc.Dbg then re-wrapping avoids pointer aliasing. Minor clarity nit: initialize Account with a named field.
- value = Account{vacc} + value = Account{Repr: vacc}
259-261
: Remove unused virtualAccountsCredits and virtualAccountsDebts
ThevirtualAccountsCredits
andvirtualAccountsDebts
fields (declared at lines 327–328) and their initializations (lines 259–260) aren’t referenced anywhere else. Drop them to eliminate dead code.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
internal/interpreter/function_exprs.go
(3 hunks)internal/interpreter/interpreter.go
(12 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
internal/interpreter/interpreter.go (6)
internal/analysis/check.go (1)
FnVarOriginVirtual
(64-64)internal/interpreter/value.go (5)
Account
(30-30)Account
(38-38)VirtualAccount
(20-20)AccountAddress
(17-17)AccountAddress
(19-19)internal/interpreter/virtual_account.go (1)
VirtualAccount
(10-14)internal/interpreter/funds_stack.go (1)
Sender
(7-11)internal/interpreter/interpreter_error.go (2)
InvalidUnboundedInSendAll
(151-154)QueryBalanceError
(179-182)internal/utils/utils.go (2)
NonExhaustiveMatchPanic
(8-10)MinBigInt
(12-22)
internal/interpreter/function_exprs.go (4)
internal/interpreter/value.go (5)
Account
(30-30)Account
(38-38)AccountAddress
(17-17)AccountAddress
(19-19)Value
(22-25)internal/interpreter/interpreter.go (1)
InterpreterError
(73-76)internal/flags/flags.go (1)
ExperimentalVirtualAccount
(13-13)internal/interpreter/virtual_account.go (1)
NewVirtualAccount
(31-36)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Tests
- GitHub Check: Dirty
🔇 Additional comments (10)
internal/interpreter/function_exprs.go (3)
29-31
: OK: use Account wrapper for balance lookup.Wrapping AccountAddress into Account for getBalance aligns with the new API.
56-61
: OK: meta() restricted to concrete account addresses.Switching to expectAccountAddress avoids ambiguous virtual/meta lookups.
159-167
: virtual() builtin correctly gated and returns Account.Feature-flag check is correct; returning Account{Repr: NewVirtualAccount()} matches the new value model.
internal/interpreter/interpreter.go (7)
197-199
: Wiring virtual() into handleFnCall looks good.The new case analysis.FnVarOriginVirtual delegates to virtual(s) as expected.
331-357
: pushSender: zero-amount fast-path and balance mutation LGTM.Early return on zero and per-color balance subtraction for real senders are appropriate.
359-369
: pushReceiver: correct dispatcher for AccountAddress vs VirtualAccount.Delegation to pushVirtualReceiver / pushReceiverAddress is clear and cohesive.
371-374
: Virtual receive doesn’t sync CachedBalances; confirm invariants.vacc.Receive appends postings but doesn’t update CachedBalances. Ensure invariants don’t rely on CachedBalances reflecting virtual settlements mid-statement; if they do, update balances accordingly.
441-447
: runSaveStatement: restricting to AccountAddress is correct.expectAccountAddress matches the storage model for saves.
811-813
: Early return on zero receive is good.Avoids unnecessary stack ops and postings for no-ops.
919-921
: Kept destination wrapping is correct.Wrapping KEPT_ADDR as Account{AccountAddress(…)} unifies downstream handling.
case VirtualAccount: | ||
// Here we have a debt from a virtual acc. | ||
// we don't want to emit that as a posting (but TODO check how does it interact with kept) | ||
senderAccountAddress.Pull(st.CurrentAsset, Sender{ | ||
AccountAddress(name), | ||
sender.Amount, | ||
sender.Color, | ||
}) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Kept-destination bug for virtual senders.
When destination is KEPT_ADDR and sender is VirtualAccount, we still call Pull, effectively removing value from the virtual account instead of “keeping” it. Mirror the AccountAddress branch: do not create postings, and ensure the amount remains available to the virtual account.
Would you like me to draft a fix once we confirm the intended kept semantics for virtual accounts?
🏁 Script executed:
#!/usr/bin/env bash
set -eo pipefail
# Show VirtualAccount branch context
rg -n 'case VirtualAccount' -C5 internal/interpreter/interpreter.go
# Show AccountAddress branch context
rg -n 'case AccountAddress' -C5 internal/interpreter/interpreter.go
# Find KEPT_ADDR references in interpreter
rg -n 'KEPT_ADDR' -C3 internal/interpreter/interpreter.go
Length of output: 5463
Handle KEPT_ADDR in VirtualAccount branch
In pushReceiverAddress (in internal/interpreter/interpreter.go around lines 394–402), add a check for name == KEPT_ADDR before calling Pull; if the destination is KEPT_ADDR, skip the Pull so the virtual account’s balance isn’t decremented.
🤖 Prompt for AI Agents
In internal/interpreter/interpreter.go around lines 395 to 403, the
VirtualAccount case always calls senderAccountAddress.Pull which wrongly
decrements a virtual account when the destination is KEPT_ADDR; add a guard that
if the destination name equals KEPT_ADDR then skip calling Pull (i.e., do
nothing for Pull in that branch) so virtual account balance is not decremented;
implement the check using the existing KEPT_ADDR constant and leave the rest of
the VirtualAccount logic unchanged.
switch account := account.Repr.(type) { | ||
case AccountAddress: | ||
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(string(account), s.CurrentAsset, *color) | ||
|
||
// that's the amount we are allowed to send (balance + overdraft) | ||
actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) | ||
// that's the amount we are allowed to send (balance + overdraft) | ||
actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) | ||
} | ||
return s.pushSender(Sender{account, actuallySentAmt, *color}, nil), nil | ||
|
||
case VirtualAccount: | ||
totalSent := big.NewInt(0) | ||
|
||
fs := account.getCredits(s.CurrentAsset) | ||
pulledSenders := fs.PullColored(amount, *color) | ||
|
||
for _, sender := range pulledSenders { | ||
s.pushSender(sender, totalSent) | ||
} | ||
|
||
// if we didn't pull enough | ||
if totalSent.Cmp(amount) == -1 { | ||
|
||
// invariant: missingAmt > 0 | ||
// (we never pull more than required) | ||
missingAmt := new(big.Int).Sub(amount, totalSent) | ||
|
||
var addionalSent *big.Int | ||
if overdraft == nil { | ||
addionalSent = new(big.Int).Set(missingAmt) | ||
} else { | ||
// TODO check this is the correct number to eventually send | ||
// TODO test overdraft | ||
addionalSent = utils.MinBigInt(overdraft, missingAmt) | ||
} | ||
|
||
s.pushSender(Sender{account, addionalSent, *color}, totalSent) | ||
} | ||
|
||
return totalSent, nil | ||
|
||
default: | ||
utils.NonExhaustiveMatchPanic[any](account) | ||
return nil, nil | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Fallback to PullAnything when no color is specified.
If evaluateColor returns "", PullColored likely narrows to uncolored only. Use PullAnything in that case to honor “any color”.
Apply this localized change:
- fs := account.getCredits(s.CurrentAsset)
- pulledSenders := fs.PullColored(amount, *color)
+ fs := account.getCredits(s.CurrentAsset)
+ var pulledSenders []Sender
+ if *color == "" {
+ pulledSenders = fs.PullAnything(amount)
+ } else {
+ pulledSenders = fs.PullColored(amount, *color)
+ }
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
switch account := account.Repr.(type) { | |
case AccountAddress: | |
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(string(account), s.CurrentAsset, *color) | |
// that's the amount we are allowed to send (balance + overdraft) | |
actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) | |
// that's the amount we are allowed to send (balance + overdraft) | |
actuallySentAmt = CalculateSafeWithdraw(balance, overdraft, amount) | |
} | |
return s.pushSender(Sender{account, actuallySentAmt, *color}, nil), nil | |
case VirtualAccount: | |
totalSent := big.NewInt(0) | |
fs := account.getCredits(s.CurrentAsset) | |
pulledSenders := fs.PullColored(amount, *color) | |
for _, sender := range pulledSenders { | |
s.pushSender(sender, totalSent) | |
} | |
// if we didn't pull enough | |
if totalSent.Cmp(amount) == -1 { | |
// invariant: missingAmt > 0 | |
// (we never pull more than required) | |
missingAmt := new(big.Int).Sub(amount, totalSent) | |
var addionalSent *big.Int | |
if overdraft == nil { | |
addionalSent = new(big.Int).Set(missingAmt) | |
} else { | |
// TODO check this is the correct number to eventually send | |
// TODO test overdraft | |
addionalSent = utils.MinBigInt(overdraft, missingAmt) | |
} | |
s.pushSender(Sender{account, addionalSent, *color}, totalSent) | |
} | |
return totalSent, nil | |
default: | |
utils.NonExhaustiveMatchPanic[any](account) | |
return nil, nil | |
} | |
case VirtualAccount: | |
totalSent := big.NewInt(0) | |
fs := account.getCredits(s.CurrentAsset) | |
var pulledSenders []Sender | |
if *color == "" { | |
pulledSenders = fs.PullAnything(amount) | |
} else { | |
pulledSenders = fs.PullColored(amount, *color) | |
} | |
for _, sender := range pulledSenders { | |
s.pushSender(sender, totalSent) | |
} | |
// if we didn't pull enough | |
if totalSent.Cmp(amount) == -1 { | |
// invariant: missingAmt > 0 | |
// (we never pull more than required) | |
missingAmt := new(big.Int).Sub(amount, totalSent) | |
var addionalSent *big.Int | |
if overdraft == nil { | |
addionalSent = new(big.Int).Set(missingAmt) | |
} else { | |
// TODO check this is the correct number to eventually send | |
// TODO test overdraft | |
addionalSent = utils.MinBigInt(overdraft, missingAmt) | |
} | |
s.pushSender(Sender{account, addionalSent, *color}, totalSent) | |
} | |
return totalSent, nil |
🤖 Prompt for AI Agents
internal/interpreter/interpreter.go around lines 656 to 704: in the
VirtualAccount branch we currently call fs.PullColored(amount, *color) which
narrows pulls to a specific color and will ignore the “any color” semantics when
evaluateColor returned an empty string; change the logic to check if *color ==
"" and call fs.PullAnything(amount) in that case, otherwise call
fs.PullColored(amount, *color); wire the resulting pulledSenders through the
same loop and keep the existing missingAmt/overdraft fallback behavior so
semantics are preserved.
switch accountRepr := account.Repr.(type) { | ||
case AccountAddress: | ||
account := string(accountRepr) | ||
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 | ||
|
||
case VirtualAccount: | ||
fs := accountRepr.credits[s.CurrentAsset] | ||
if fs == nil { | ||
return big.NewInt(0), nil | ||
} | ||
|
||
lst := fs.senders | ||
sum := big.NewInt(0) | ||
|
||
for lst != nil { | ||
if lst.Head.Color == "" { | ||
sum.Add(sum, lst.Head.Amount) | ||
} | ||
lst = lst.Tail | ||
} | ||
return sum, nil | ||
|
||
default: | ||
utils.NonExhaustiveMatchPanic[any](account.Repr) | ||
return nil, nil | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getBalance uses CurrentAsset for VirtualAccount — wrong asset.
VirtualAccount balance computation ignores the asset param and reads s.CurrentAsset, which can be unset or different. Use the function’s asset argument. Also, keep summing only uncolored credits to match address-path behavior.
Apply this fix:
func getBalance(
s *programState,
- account Account,
- asset string,
+ account Account,
+ asset string,
) (*big.Int, InterpreterError) {
switch accountRepr := account.Repr.(type) {
case AccountAddress:
account := string(accountRepr)
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
case VirtualAccount:
- fs := accountRepr.credits[s.CurrentAsset]
+ fs := accountRepr.credits[asset]
if fs == nil {
return big.NewInt(0), nil
}
lst := fs.senders
sum := big.NewInt(0)
for lst != nil {
if lst.Head.Color == "" {
sum.Add(sum, lst.Head.Amount)
}
lst = lst.Tail
}
return sum, nil
default:
utils.NonExhaustiveMatchPanic[any](account.Repr)
return nil, nil
}
}
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
switch accountRepr := account.Repr.(type) { | |
case AccountAddress: | |
account := string(accountRepr) | |
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 | |
case VirtualAccount: | |
fs := accountRepr.credits[s.CurrentAsset] | |
if fs == nil { | |
return big.NewInt(0), nil | |
} | |
lst := fs.senders | |
sum := big.NewInt(0) | |
for lst != nil { | |
if lst.Head.Color == "" { | |
sum.Add(sum, lst.Head.Amount) | |
} | |
lst = lst.Tail | |
} | |
return sum, nil | |
default: | |
utils.NonExhaustiveMatchPanic[any](account.Repr) | |
return nil, nil | |
func getBalance( | |
s *programState, | |
account Account, | |
asset string, | |
) (*big.Int, InterpreterError) { | |
switch accountRepr := account.Repr.(type) { | |
case AccountAddress: | |
account := string(accountRepr) | |
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 | |
case VirtualAccount: | |
// Use the function’s asset argument instead of s.CurrentAsset | |
fs := accountRepr.credits[asset] | |
if fs == nil { | |
return big.NewInt(0), nil | |
} | |
lst := fs.senders | |
sum := big.NewInt(0) | |
for lst != nil { | |
if lst.Head.Color == "" { | |
sum.Add(sum, lst.Head.Amount) | |
} | |
lst = lst.Tail | |
} | |
return sum, nil | |
default: | |
utils.NonExhaustiveMatchPanic[any](account.Repr) | |
return nil, nil | |
} | |
} |
🤖 Prompt for AI Agents
In internal/interpreter/interpreter.go around lines 996 to 1027, the
VirtualAccount branch is using s.CurrentAsset instead of the function's asset
parameter and thus may compute balances for the wrong asset; change fs :=
accountRepr.credits[s.CurrentAsset] to fs := accountRepr.credits[asset] so the
lookup uses the passed-in asset, and keep the existing Color == "" check so only
uncolored credits are summed (preserving address-path behavior); ensure nil
checks remain and return the computed sum as before.
No description provided.