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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
23 changes: 15 additions & 8 deletions internal/migrations/049-maa-funding.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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;
};
Expand Down
10 changes: 5 additions & 5 deletions tests/streams/maa/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
133 changes: 133 additions & 0 deletions tests/streams/maa/route_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading