diff --git a/go.mod b/go.mod index 301e5fcd..b7bcf62c 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,8 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 - github.com/trufnetwork/kwil-db v0.10.3-0.20260504105445-236471e6a1fe - github.com/trufnetwork/kwil-db/core v0.4.3-0.20260504105445-236471e6a1fe + github.com/trufnetwork/kwil-db v0.10.3-0.20260605080707-350a1bb51469 + github.com/trufnetwork/kwil-db/core v0.4.3-0.20260605080707-350a1bb51469 github.com/trufnetwork/sdk-go v0.6.4-0.20260224122406-a741343e2f37 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa diff --git a/go.sum b/go.sum index ddfdec14..24ca89be 100644 --- a/go.sum +++ b/go.sum @@ -1278,6 +1278,8 @@ github.com/trufnetwork/kwil-db v0.10.3-0.20260502065007-81722689e25b h1:fkoEUDIF github.com/trufnetwork/kwil-db v0.10.3-0.20260502065007-81722689e25b/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= github.com/trufnetwork/kwil-db v0.10.3-0.20260504105445-236471e6a1fe h1:hEW7eFTX3gS+WzhdQrh6xR/Zzzub25Zy/kdkosAljFQ= github.com/trufnetwork/kwil-db v0.10.3-0.20260504105445-236471e6a1fe/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= +github.com/trufnetwork/kwil-db v0.10.3-0.20260605080707-350a1bb51469 h1:1yHa+gMACjnKSk1GO2Szy8kCgPkqGu4zF9ZdOK3Dbl8= +github.com/trufnetwork/kwil-db v0.10.3-0.20260605080707-350a1bb51469/go.mod h1:LiBAC48uZl2B0IiLtD2hpOce7RNfpuDdghVAOc3u1Qo= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260406143732-e25a390e62bb h1:tghQHOXYDHwjBmhEVAQESPTvf3O9Oq/1qzow5aKE81c= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260406143732-e25a390e62bb/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260413125950-e0bc3b09a211 h1:h5HpwEqbPDEo4uGYz7ZUTfQ9tJBKyHaCmtwk2boB7Xs= @@ -1310,6 +1312,8 @@ github.com/trufnetwork/kwil-db/core v0.4.3-0.20260502065007-81722689e25b h1:bpX8 github.com/trufnetwork/kwil-db/core v0.4.3-0.20260502065007-81722689e25b/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260504105445-236471e6a1fe h1:0wvs4osJSaf+gm7yzSAzMHo+VL7KPRkyvHec8hglzkg= github.com/trufnetwork/kwil-db/core v0.4.3-0.20260504105445-236471e6a1fe/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= +github.com/trufnetwork/kwil-db/core v0.4.3-0.20260605080707-350a1bb51469 h1:FXAeaGYdVrW/K6qpRDLtQaIlMkeH0ADUsMVKg5GrJ1o= +github.com/trufnetwork/kwil-db/core v0.4.3-0.20260605080707-350a1bb51469/go.mod h1:HnOsh9+BN13LJCjiH0+XKaJzyjWKf+H9AofFFp90KwQ= github.com/trufnetwork/openzeppelin-merkle-tree-go v0.0.2 h1:DCq8MzbWH0wZmICNmMVsSzUHUPl+2vqRhluEABjxl88= github.com/trufnetwork/openzeppelin-merkle-tree-go v0.0.2/go.mod h1:Y0MJpPp9QXU5vC6Gpoilql2NkgmGNcbHm9HYC2v2N8s= github.com/trufnetwork/sdk-go v0.6.4-0.20260224122406-a741343e2f37 h1:VD/GWxLTshaXpLukEc1SXbG7QA9HrFzF8JvxJAJ/x7Q= diff --git a/internal/migrations/049-maa-funding.sql b/internal/migrations/049-maa-funding.sql index 2bc156db..e2b76063 100644 --- a/internal/migrations/049-maa-funding.sql +++ b/internal/migrations/049-maa-funding.sql @@ -16,11 +16,16 @@ * nothing. The recipients are always resolved from the store (restricted = commission, * unrestricted = payout) — never from a caller argument — so the destinations cannot be * steered. Each withdrawal is two legs (commission -> restricted, payout -> owner/L1) in a - * single action body, so a failure on either leg rolls the whole move back. + * single action, and every transaction's route body runs inside its own nested DB + * transaction that is rolled back when the route errors — so a failure on either leg + * leaves nothing applied (the engine itself does not undo an aborted action's earlier + * writes; the per-transaction boundary does). * - * Fee math: 'bps' = floor(gross * fee_bps / 10000); 'flat' = fee_flat in the withdrawn - * token's own base units. The commission is deducted FROM the gross (the owner receives the - * remainder), clamped to never exceed the gross. There is no protocol fee cap. + * Fee math: 'bps' = gross * fee_bps / 10000, rounded HALF-UP to a whole base unit (the + * engine's NUMERIC rounding mode); 'flat' = fee_flat in the withdrawn token's own base + * units. The commission is deducted FROM the gross (the owner receives the remainder), so + * the two legs always sum to the gross exactly regardless of rounding; it is clamped to + * never exceed the gross. There is no protocol fee cap. * * Token-agnostic: an agent wallet can hold several bridged tokens, so withdrawal targets one * bridge per call via the $bridge argument. The bridge namespace is the per-token instance: @@ -74,8 +79,8 @@ PRIVATE { -- ============================================================================= -- maa_commission: the commission charged on a gross withdrawal, per the rule's fee terms. --- 'flat' returns fee_flat verbatim (the withdrawn token's base units); 'bps' floors --- gross * fee_bps / 10000 (same integer-division idiom as the settlement fee split). +-- 'flat' returns fee_flat verbatim (the withdrawn token's base units); 'bps' computes +-- gross * fee_bps / 10000, rounded half-up to a whole base unit. -- ============================================================================= CREATE OR REPLACE ACTION maa_commission( $fee_mode TEXT, @@ -86,8 +91,10 @@ CREATE OR REPLACE ACTION maa_commission( if $fee_mode = 'flat' { RETURN $fee_flat; } - -- Assigning to a NUMERIC(78, 0) variable truncates the fractional part (floor for the - -- non-negative values here); the caller recaptures the remainder as payout = gross - commission. + -- The division is brought to scale 0 with the engine's NUMERIC rounding mode, which is + -- ROUND HALF-UP (e.g. 19999 * 250 / 10000 = 499.975 -> 500; .5 rounds away from zero). + -- The dust direction never creates or destroys funds: the caller pays out + -- gross - commission, so the two legs always sum to the gross exactly. $c NUMERIC(78, 0) := ($gross * $fee_bps::NUMERIC(78, 0)) / 10000::NUMERIC(78, 0); RETURN $c; }; diff --git a/tests/streams/maa/create_test.go b/tests/streams/maa/create_test.go index 3cb98c51..f23aec14 100644 --- a/tests/streams/maa/create_test.go +++ b/tests/streams/maa/create_test.go @@ -83,11 +83,11 @@ func createDefaultRule(t *testing.T, ctx context.Context, platform *kwilTesting. t.Helper() var ruleID []byte err := callAs(ctx, platform, restricted, "maa_create_rule", []any{ - salt, // $salt - "bps", // $fee_mode - feeBps, // $fee_bps - dec(t, "0"), // $fee_flat - []string{"main", "main"}, // $namespaces + salt, // $salt + "bps", // $fee_mode + feeBps, // $fee_bps + dec(t, "0"), // $fee_flat + []string{"main", "main"}, // $namespaces []string{"ob_place_order", "ob_cancel_order"}, // $actions [][]byte{repeat(0xcc, 32), nil}, // $body_hashes }, func(row *common.Row) error { diff --git a/tests/streams/maa/route_e2e_test.go b/tests/streams/maa/route_e2e_test.go new file mode 100644 index 00000000..3da8853a --- /dev/null +++ b/tests/streams/maa/route_e2e_test.go @@ -0,0 +1,133 @@ +//go:build kwiltest + +package maa + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + erc20bridge "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" + orderedsync "github.com/trufnetwork/kwil-db/node/exts/ordered-sync" + kwilTesting "github.com/trufnetwork/kwil-db/testing" + "github.com/trufnetwork/node/internal/migrations" + testutils "github.com/trufnetwork/node/tests/streams/utils" + "github.com/trufnetwork/sdk-go/core/util" +) + +// This file is the end-to-end integration of the agent-wallet withdrawal, now possible +// because node's kwil-db pin carries the full MAA stack (the MAAExec route, the +// TxContext.MAARestricted flag, and the erc20 token boundary that enforces it). +// +// Driving the literal maaExecRoute.Execute would need a full TxApp (signer, accounts, +// validators, gas/nonce) that the kwiltest Platform does not stand up, and the route type is +// unexported. So the test reproduces exactly the child execution context the route builds +// for its inner call (kwil-db node/txapp/maa_route.go:186-189): @caller rewritten to the MAA +// address, and TxContext.MAARestricted = (role == "restricted"). With that, it exercises the +// REAL pieces the route integrates — the 048 store getters that resolve the role, the 049 +// withdrawal actions that move the money, and the #3 erc20 MAARestricted boundary on REAL +// balances. The route's own gate / role-resolution / caller-rewrite branch logic is unit +// tested upstream in kwil-db (node/txapp/maa_route_test.go). + +func TestMAAWithdrawRouteE2E(t *testing.T) { + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "MAA_Withdraw_RouteE2E", + SeedStatements: migrations.GetSeedScriptStatements(), + FunctionTests: []kwilTesting.TestFunc{ + testMAAWithdrawE2E(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// callAsMAA invokes an action exactly as the maa_exec route's inner call does: @caller is the +// MAA address and TxContext.MAARestricted carries the signer's role (true for the restricted +// agent, false for the unrestricted owner). The flag threads by pointer to any call depth, so +// the erc20 token boundary sees it inside the nested transfer/bridge primitives. +func callAsMAA(ctx context.Context, platform *kwilTesting.Platform, maa util.EthereumAddress, restricted bool, action string, args []any) error { + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{Height: 1}, + Signer: maa.Bytes(), + Caller: maa.Address(), + TxID: platform.Txid(), + MAARestricted: restricted, + } + res, err := platform.Engine.Call(&common.EngineContext{TxContext: tx}, platform.DB, "", action, args, func(*common.Row) error { return nil }) + if err != nil { + return err + } + return res.Error +} + +func testMAAWithdrawE2E(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) + unrestricted := util.Unsafe_NewEthereumAddressFromString(unrestrictedHex) + platform.Deployer = restricted.Bytes() + + // A rule whose allow-list is ONLY order-book actions — maa_withdraw is deliberately + // NOT allow-listed, mirroring the real design: the owner's exit is a route privilege, + // not an allow-list grant. 250 bps commission; 100 USDC funded into the MAA. + require.NoError(t, erc20bridge.ForTestingInitializeExtension(ctx, platform)) + ruleID := createDefaultRule(t, ctx, platform, restricted, 250, repeat(0xab, 32)) + maa := joinRule(t, ctx, platform, unrestricted, ruleID) + maaAddr := addrFromBytes(maa) + orderedsync.ForTestingReset() + fundAddress(t, ctx, platform, maaAddr.Address(), "100000000000000000000", 1) + + // 1) Role resolution source of truth: the route reads maa_get_instance to decide the + // signer's role. Assert it maps the MAA to both component keys (the data the + // route's raw-byte role compare uses). + var instRestricted, instUnrestricted string + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_instance", []any{maa}, + func(row *common.Row) error { + instRestricted = row.Values[2].(string) + instUnrestricted = row.Values[3].(string) + return nil + })) + require.Equal(t, restrictedHex, instRestricted, "the route resolves the agent (restricted) from the store") + require.Equal(t, unrestrictedHex, instUnrestricted, "the route resolves the owner (unrestricted) from the store") + + // 2) maa_withdraw is NOT in the allow-list — so the owner reaching it below is the + // route's owner-exit bypass, not an allow-list grant. + var allowed []string + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_allowed_actions", []any{ruleID}, + func(row *common.Row) error { allowed = append(allowed, row.Values[1].(string)); return nil })) + require.NotContains(t, allowed, "maa_withdraw", "the exit must NOT be allow-listed (it is a route privilege)") + + // 3) RESTRICTED agent path. The route rejects this at its gate before re-entry, but + // this proves the HARD backstop: even if the agent reached maa_withdraw with + // MAARestricted set, the erc20 token boundary blocks the (commission) transfer leg + // and nothing moves. The guard fires before any state mutation, so balances are + // untouched without needing a rollback. + err := callAsMAA(ctx, platform, maaAddr, true /* restricted */, "maa_withdraw", + []any{wdBridge, dec(t, "100000000000000000000")}) + require.Error(t, err, "a restricted agent must not be able to withdraw") + require.ErrorContains(t, err, "restricted agent (MAA) execution", + "the withdrawal must be stopped by the erc20 token boundary, not some incidental error") + require.Equal(t, "100000000000000000000", balance(t, ctx, platform, maaAddr.Address()), "the agent's blocked withdrawal must move nothing") + require.Equal(t, "0", balance(t, ctx, platform, restrictedHex), "no commission was paid on the blocked attempt") + + // 4) UNRESTRICTED owner path. The same inner call with MAARestricted=false (as the + // route sets it for the owner) runs end to end: commission -> agent, payout -> + // owner, MAA drained, audit event recorded. + err = callAsMAA(ctx, platform, maaAddr, false /* unrestricted owner */, "maa_withdraw", + []any{wdBridge, dec(t, "100000000000000000000")}) + require.NoError(t, err, "the owner's withdrawal must succeed end to end") + require.Equal(t, "2500000000000000000", balance(t, ctx, platform, restrictedHex), "agent earns the 2.5% commission") + require.Equal(t, "97500000000000000000", balance(t, ctx, platform, unrestrictedHex), "owner receives the remainder") + require.Equal(t, "0", balance(t, ctx, platform, maaAddr.Address()), "the agent wallet is drained") + + var sawWithdraw bool + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_events", []any{ruleID, int64(100), int64(0)}, + func(row *common.Row) error { + if row.Values[2].(string) == "WITHDRAW" { + sawWithdraw = true + } + return nil + })) + require.True(t, sawWithdraw, "the successful withdrawal records a WITHDRAW audit event") + return nil + } +} diff --git a/tests/streams/maa/withdraw_test.go b/tests/streams/maa/withdraw_test.go index abd647bb..83bbef46 100644 --- a/tests/streams/maa/withdraw_test.go +++ b/tests/streams/maa/withdraw_test.go @@ -12,6 +12,7 @@ import ( kwilTypes "github.com/trufnetwork/kwil-db/core/types" erc20bridge "github.com/trufnetwork/kwil-db/node/exts/erc20-bridge/erc20" orderedsync "github.com/trufnetwork/kwil-db/node/exts/ordered-sync" + "github.com/trufnetwork/kwil-db/node/types/sql" kwilTesting "github.com/trufnetwork/kwil-db/testing" "github.com/trufnetwork/node/internal/migrations" testutils "github.com/trufnetwork/node/tests/streams/utils" @@ -20,12 +21,17 @@ import ( ) // The withdrawal tests run against the hoodi_tt2 (USDC) test bridge — one of the per-token -// instances an agent wallet can hold. Balances are 18-decimal in the test harness. +// instances an agent wallet can hold. Balances are 18-decimal in the test harness. The +// hoodi_tt (TRUF) bridge is a second, independent instance used to prove per-token isolation. const ( wdBridge = "hoodi_tt2" wdChain = "hoodi" wdEscrow = "0x80D9B3b6941367917816d36748C88B303f7F1415" wdERC20 = "0x1591DeAa21710E0BA6CC1b15F49620C9F65B2dEd" + + wdTRUFBridge = "hoodi_tt" + wdTRUFEscrow = "0x878d6aaeb6e746033f50b8dc268d54b4631554e7" + wdTRUFERC20 = "0x263ce78fef26600e4e428cebc91c2a52484b4fbf" ) func TestMAAWithdraw(t *testing.T) { @@ -37,6 +43,9 @@ func TestMAAWithdraw(t *testing.T) { testMAAWithdrawGuards(t), testMAAWithdrawFlatFeeClamped(t), testMAABridgeOut(t), + testMAAWithdrawBpsRounding(t), + testMAABridgeOutExplicitRecipientAndAtomicity(t), + testMAAWithdrawMultiTokenIsolation(t), }, }, testutils.GetTestOptionsWithCache()) } @@ -68,6 +77,48 @@ func balance(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, return b } +// fundAddressTRUF credits `to` on the hoodi_tt (TRUF) bridge — a second, independent +// per-token instance (its ordered-sync topic is separate from hoodi_tt2's, so a first +// deposit at point 1 with no predecessor processes independently). +func fundAddressTRUF(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, to string, amount string, point int64) { + t.Helper() + require.NoError(t, testerc20.InjectERC20Transfer( + ctx, platform, wdChain, wdTRUFEscrow, wdTRUFERC20, to, to, amount, point, nil, + )) +} + +// balanceTRUF reads an address's hoodi_tt (TRUF) ledger balance ("0" when there is no row). +func balanceTRUF(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, addr string) string { + t.Helper() + b, err := testerc20.GetUserBalance(ctx, platform, wdTRUFBridge, addr) + require.NoError(t, err) + if b == "" || b == "" { + return "0" + } + return b +} + +// callAsOn is callAs against an explicit DB handle. The atomicity test uses it to run a +// withdrawal inside a nested transaction (savepoint) — the same boundary the block +// processor gives every transaction in production: the route body runs in a nested tx +// that is rolled back when the route returns an error, so a failed transaction's writes +// are discarded as a unit. +func callAsOn(ctx context.Context, platform *kwilTesting.Platform, db sql.DB, caller util.EthereumAddress, action string, args []any) error { + tx := &common.TxContext{ + Ctx: ctx, + BlockContext: &common.BlockContext{Height: 1}, + Signer: caller.Bytes(), + Caller: caller.Address(), + TxID: platform.Txid(), + } + engineCtx := &common.EngineContext{TxContext: tx} + res, err := platform.Engine.Call(engineCtx, db, "", action, args, func(*common.Row) error { return nil }) + if err != nil { + return err + } + return res.Error +} + // setupFundedMAA creates a rule (restricted), joins it (unrestricted), funds the derived MAA // with `fund` base units, and returns the MAA address and the created rule ID. The ERC20 // extension is initialized first. @@ -224,3 +275,118 @@ func testMAABridgeOut(t *testing.T) func(context.Context, *kwilTesting.Platform) return nil } } + +// testMAAWithdrawBpsRounding pins the bps commission's rounding direction on non-divisible +// amounts: the engine brings the division to scale 0 with ROUND HALF-UP. Two partial +// withdrawals (also covering the partial-withdraw shape) discriminate the three candidate +// behaviors: 19999 @ 250 bps = 499.975 -> 500 (rejects floor's 499); 20020 @ 250 bps = +// 500.5 -> 501 (rejects banker's 500). Funds conserve regardless: payout = gross - commission. +func testMAAWithdrawBpsRounding(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) + unrestricted := util.Unsafe_NewEthereumAddressFromString(unrestrictedHex) + platform.Deployer = restricted.Bytes() + + maa, _ := setupFundedMAA(t, ctx, platform, restricted, unrestricted, 250, repeat(0xab, 32), "100000") + maaAddr := addrFromBytes(maa) + + // 19999 * 250 / 10000 = 499.975 -> commission 500 (floor would give 499). + require.NoError(t, callAs(ctx, platform, maaAddr, "maa_withdraw", []any{wdBridge, dec(t, "19999")}, nil)) + require.Equal(t, "500", balance(t, ctx, platform, restrictedHex), "499.975 must round half-up to 500") + require.Equal(t, "19499", balance(t, ctx, platform, unrestrictedHex), "owner receives gross - commission") + require.Equal(t, "80001", balance(t, ctx, platform, maaAddr.Address()), "partial withdraw leaves the remainder") + + // 20020 * 250 / 10000 = 500.5 -> commission 501 (banker's rounding would give 500). + require.NoError(t, callAs(ctx, platform, maaAddr, "maa_withdraw", []any{wdBridge, dec(t, "20020")}, nil)) + require.Equal(t, "1001", balance(t, ctx, platform, restrictedHex), "the .5 fraction must round away from zero") + require.Equal(t, "39018", balance(t, ctx, platform, unrestrictedHex)) + require.Equal(t, "59981", balance(t, ctx, platform, maaAddr.Address())) + return nil + } +} + +// testMAABridgeOutExplicitRecipientAndAtomicity covers the two untested bridge-out shapes: +// (1) ATOMICITY — a payout leg that fails AFTER the commission leg has executed must roll +// back the whole withdrawal, leaving every balance untouched; (2) an explicit L1 recipient +// (instead of the default owner) works and still pays the commission, and the WITHDRAW +// audit event is recorded for the bridge-out variant too. +func testMAABridgeOutExplicitRecipientAndAtomicity(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) + unrestricted := util.Unsafe_NewEthereumAddressFromString(unrestrictedHex) + platform.Deployer = restricted.Bytes() + + maa, ruleID := setupFundedMAA(t, ctx, platform, restricted, unrestricted, 250, repeat(0xab, 32), "100000000000000000000") + maaAddr := addrFromBytes(maa) + + // 1) A malformed recipient makes the payout leg fail AFTER the commission leg ran. + // Production wraps every transaction's route body in a nested DB transaction + // that is rolled back when the route errors — the engine itself does not undo + // an aborted action's earlier writes; the tx boundary does. Emulate exactly + // that boundary: run the failing withdrawal in a savepoint and roll it back on + // error. This pins two real properties: the payout-leg error propagates (a + // swallowed error would make this call "succeed"), and every write is confined + // to the passed DB handle (a write outside it would survive the rollback). + spTx, err := platform.DB.BeginTx(ctx) + require.NoError(t, err) + // Guard every failure path: a failed require aborts this function (Goexit + // still runs defers), and without this the savepoint would leak open on the + // shared session. A second Rollback after the explicit one below is a no-op. + defer func() { _ = spTx.Rollback(ctx) }() + err = callAsOn(ctx, platform, spTx, maaAddr, "maa_bridge_out", + []any{wdBridge, dec(t, "100000000000000000000"), "not-an-address"}) + require.Error(t, err, "a payout-leg failure must abort the withdrawal") + require.NoError(t, spTx.Rollback(ctx)) + require.Equal(t, "100000000000000000000", balance(t, ctx, platform, maaAddr.Address()), "failed withdrawal must move nothing") + require.Equal(t, "0", balance(t, ctx, platform, restrictedHex), "the commission leg must be confined to the rolled-back tx") + + // 2) An explicit L1 recipient: commission still lands with the agent, the MAA is + // drained, and neither the owner nor the recipient is credited internally. + recipient := "0x9999999999999999999999999999999999999999" + require.NoError(t, callAs(ctx, platform, maaAddr, "maa_bridge_out", + []any{wdBridge, dec(t, "100000000000000000000"), recipient}, nil), + "bridge-out to an explicit recipient must succeed") + require.Equal(t, "2500000000000000000", balance(t, ctx, platform, restrictedHex), "agent earns the commission") + require.Equal(t, "0", balance(t, ctx, platform, maaAddr.Address()), "agent wallet is drained") + require.Equal(t, "0", balance(t, ctx, platform, unrestrictedHex), "owner is not credited internally on an L1 bridge-out") + require.Equal(t, "0", balance(t, ctx, platform, recipient), "the recipient is paid on L1, not on the internal ledger") + + // The bridge-out variant records a WITHDRAW audit event with the gross amount. + var withdrawAmount string + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_events", []any{ruleID, int64(100), int64(0)}, + func(row *common.Row) error { + if row.Values[2].(string) == "WITHDRAW" && row.Values[7] != nil { + withdrawAmount = row.Values[7].(*kwilTypes.Decimal).String() + } + return nil + })) + require.Equal(t, "100000000000000000000", withdrawAmount, "bridge-out must record a WITHDRAW event with the gross") + return nil + } +} + +// testMAAWithdrawMultiTokenIsolation: an agent wallet holds several bridged tokens, one +// ledger row per (instance, address). Withdrawing one token must not touch another — +// the per-token boundary the $bridge argument selects. +func testMAAWithdrawMultiTokenIsolation(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) + unrestricted := util.Unsafe_NewEthereumAddressFromString(unrestrictedHex) + platform.Deployer = restricted.Bytes() + + maa, _ := setupFundedMAA(t, ctx, platform, restricted, unrestricted, 250, repeat(0xab, 32), "100000000000000000000") + maaAddr := addrFromBytes(maa) + // Fund the SAME wallet on the second token's instance (independent topic, first point). + fundAddressTRUF(t, ctx, platform, maaAddr.Address(), "50000000000000000000", 1) + require.Equal(t, "50000000000000000000", balanceTRUF(t, ctx, platform, maaAddr.Address()), "TRUF funding must land") + + // Withdraw the whole USDC balance; the TRUF balance must be untouched. + require.NoError(t, callAs(ctx, platform, maaAddr, "maa_withdraw", []any{wdBridge, dec(t, "100000000000000000000")}, nil)) + require.Equal(t, "0", balance(t, ctx, platform, maaAddr.Address()), "USDC drained") + require.Equal(t, "2500000000000000000", balance(t, ctx, platform, restrictedHex), "USDC commission paid") + require.Equal(t, "50000000000000000000", balanceTRUF(t, ctx, platform, maaAddr.Address()), "TRUF balance untouched by a USDC withdrawal") + require.Equal(t, "0", balanceTRUF(t, ctx, platform, restrictedHex), "no TRUF commission was charged") + require.Equal(t, "0", balanceTRUF(t, ctx, platform, unrestrictedHex), "no TRUF payout occurred") + return nil + } +}