From 3f16292ccf15a30685be3438a5c034b782a0c273 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Mon, 1 Jun 2026 22:08:00 +0700 Subject: [PATCH 1/3] feat(tn_utils): add maa address derivation precompiles --- extensions/tn_utils/maa.go | 349 +++++++++++++++++++++++++++++ extensions/tn_utils/maa_test.go | 183 +++++++++++++++ extensions/tn_utils/precompiles.go | 2 + 3 files changed, 534 insertions(+) create mode 100644 extensions/tn_utils/maa.go create mode 100644 extensions/tn_utils/maa_test.go diff --git a/extensions/tn_utils/maa.go b/extensions/tn_utils/maa.go new file mode 100644 index 00000000..ae679b2a --- /dev/null +++ b/extensions/tn_utils/maa.go @@ -0,0 +1,349 @@ +package tn_utils + +// Modular Agent Address (MAA) derivation precompiles. +// +// Two pure, deterministic functions back the MAA rule store (migration 048): +// +// tn_utils.compute_rules_hash(fee_mode, fee_bps, fee_flat, bridge, namespaces[], actions[], body_hashes[]) +// -> keccak256(RULES_PREIMAGE) (32 bytes) +// tn_utils.derive_maa_address(restricted, unrestricted, rules_hash, salt) +// -> keccak256(ADDRESS_PREIMAGE)[12:32] (20 bytes) +// +// The exact byte layout is frozen in 0GoalModularAgentAddresses/5RulesHash-Preimage-Spec.md and +// MUST stay byte-identical to the SDK implementations (a mismatch sends funds to the wrong address). +// keccak256 here is Ethereum/legacy Keccak (go-ethereum crypto.Keccak256), NOT NIST SHA3-256. + +import ( + "bytes" + "encoding/binary" + "fmt" + "math" + "math/big" + "sort" + + ethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/trufnetwork/kwil-db/common" + "github.com/trufnetwork/kwil-db/core/types" + "github.com/trufnetwork/kwil-db/extensions/precompiles" +) + +const ( + maaRulesVersion byte = 0x01 // RULES_PREIMAGE leading version byte (doc 5 §1) + maaAddrVersion byte = 0x01 // ADDRESS_PREIMAGE leading version byte (doc 5 §2) +) + +// --------------------------------------------------------------------------- +// derive_maa_address +// --------------------------------------------------------------------------- + +func deriveMAAAddressMethod() precompiles.Method { + return precompiles.Method{ + Name: "derive_maa_address", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("restricted", types.ByteaType, false), + precompiles.NewPrecompileValue("unrestricted", types.ByteaType, false), + precompiles.NewPrecompileValue("rules_hash", types.ByteaType, false), + precompiles.NewPrecompileValue("salt", types.ByteaType, true), // nullable: empty salt allowed + }, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("maa_address", types.ByteaType, false), + }, + }, + Handler: deriveMAAAddressHandler, + } +} + +func deriveMAAAddressHandler(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + restricted, err := toByteSliceAllowNil(inputs[0]) + if err != nil { + return fmt.Errorf("restricted: %w", err) + } + unrestricted, err := toByteSliceAllowNil(inputs[1]) + if err != nil { + return fmt.Errorf("unrestricted: %w", err) + } + rulesHash, err := toByteSliceAllowNil(inputs[2]) + if err != nil { + return fmt.Errorf("rules_hash: %w", err) + } + salt, err := toByteSliceAllowNil(inputs[3]) + if err != nil { + return fmt.Errorf("salt: %w", err) + } + + addr, err := deriveMAAAddress(restricted, unrestricted, rulesHash, salt) + if err != nil { + return err + } + return resultFn([]any{addr}) +} + +// deriveMAAAddress builds the canonical ADDRESS_PREIMAGE (doc 5 §2) and returns the low 20 bytes +// of keccak256(preimage) — the Ethereum-style MAA address. +func deriveMAAAddress(restricted, unrestricted, rulesHash, salt []byte) ([]byte, error) { + if len(restricted) != 20 { + return nil, fmt.Errorf("restricted must be 20 bytes, got %d", len(restricted)) + } + if len(unrestricted) != 20 { + return nil, fmt.Errorf("unrestricted must be 20 bytes, got %d", len(unrestricted)) + } + if len(rulesHash) != 32 { + return nil, fmt.Errorf("rules_hash must be 32 bytes, got %d", len(rulesHash)) + } + + // ADDRESS_PREIMAGE = version ‖ restricted ‖ unrestricted ‖ rules_hash ‖ salt (salt last/variable) + var buf bytes.Buffer + buf.WriteByte(maaAddrVersion) + buf.Write(restricted) + buf.Write(unrestricted) + buf.Write(rulesHash) + buf.Write(salt) + + full := ethcrypto.Keccak256(buf.Bytes()) // 32 bytes + out := make([]byte, 20) + copy(out, full[12:32]) // low 20 bytes + return out, nil +} + +// --------------------------------------------------------------------------- +// compute_rules_hash +// --------------------------------------------------------------------------- + +func computeRulesHashMethod() precompiles.Method { + return precompiles.Method{ + Name: "compute_rules_hash", + AccessModifiers: []precompiles.Modifier{precompiles.VIEW, precompiles.PUBLIC}, + Parameters: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("fee_mode", types.TextType, false), + precompiles.NewPrecompileValue("fee_bps", types.IntType, false), + precompiles.NewPrecompileValue("fee_flat", types.TextType, false), // decimal string of base units + precompiles.NewPrecompileValue("bridge", types.TextType, false), + precompiles.NewPrecompileValue("namespaces", types.TextArrayType, false), + precompiles.NewPrecompileValue("actions", types.TextArrayType, false), + precompiles.NewPrecompileValue("body_hashes", types.ByteaArrayType, true), // nullable elements + }, + Returns: &precompiles.MethodReturn{ + IsTable: false, + Fields: []precompiles.PrecompileValue{ + precompiles.NewPrecompileValue("rules_hash", types.ByteaType, false), + }, + }, + Handler: computeRulesHashHandler, + } +} + +type maaAllowEntry struct { + namespace string + action string + bodyHash []byte // nil/empty = none, else 32 bytes +} + +func computeRulesHashHandler(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { + feeMode, err := toStr(inputs[0]) + if err != nil { + return fmt.Errorf("fee_mode: %w", err) + } + feeBps, err := toInt64(inputs[1]) + if err != nil { + return fmt.Errorf("fee_bps: %w", err) + } + feeFlatStr, err := toStr(inputs[2]) + if err != nil { + return fmt.Errorf("fee_flat: %w", err) + } + bridge, err := toStr(inputs[3]) + if err != nil { + return fmt.Errorf("bridge: %w", err) + } + namespaces, err := toStringSliceArray(inputs[4]) + if err != nil { + return fmt.Errorf("namespaces: %w", err) + } + actions, err := toStringSliceArray(inputs[5]) + if err != nil { + return fmt.Errorf("actions: %w", err) + } + var bodyHashes [][]byte + if inputs[6] != nil { + bodyHashes, err = toByteSliceArray(inputs[6]) + if err != nil { + return fmt.Errorf("body_hashes: %w", err) + } + } + // Empty allow-list with a NULL body_hashes array → treat as zero-length to match namespaces/actions. + if len(bodyHashes) == 0 && len(namespaces) > 0 { + bodyHashes = make([][]byte, len(namespaces)) + } + + if len(namespaces) != len(actions) || len(namespaces) != len(bodyHashes) { + return fmt.Errorf("namespaces/actions/body_hashes must be equal length (%d/%d/%d)", + len(namespaces), len(actions), len(bodyHashes)) + } + + hash, err := computeRulesHash(feeMode, feeBps, feeFlatStr, bridge, namespaces, actions, bodyHashes) + if err != nil { + return err + } + return resultFn([]any{hash}) +} + +// computeRulesHash builds the canonical RULES_PREIMAGE (doc 5 §1) and returns keccak256(preimage). +func computeRulesHash(feeMode string, feeBps int64, feeFlatStr, bridge string, namespaces, actions []string, bodyHashes [][]byte) ([]byte, error) { + var b bytes.Buffer + + b.WriteByte(maaRulesVersion) + + switch feeMode { + case "bps": + b.WriteByte(0x00) + case "flat": + b.WriteByte(0x01) + default: + return nil, fmt.Errorf("fee_mode must be 'bps' or 'flat', got %q", feeMode) + } + + if feeBps < 0 || feeBps > math.MaxUint32 { + return nil, fmt.Errorf("fee_bps out of uint32 range: %d", feeBps) + } + var bps [4]byte + binary.BigEndian.PutUint32(bps[:], uint32(feeBps)) + b.Write(bps[:]) + + feeFlat, err := parseFeeFlat(feeFlatStr) + if err != nil { + return nil, err + } + var ff [32]byte + feeFlat.FillBytes(ff[:]) // big-endian, left-zero-padded + b.Write(ff[:]) + + if err := maaWriteLP8(&b, []byte(bridge)); err != nil { + return nil, fmt.Errorf("bridge: %w", err) + } + + // Canonicalize: dedup by (namespace, action), sort bytewise. + dedup := make(map[string]maaAllowEntry, len(namespaces)) + for i := range namespaces { + e := maaAllowEntry{namespace: namespaces[i], action: actions[i], bodyHash: bodyHashes[i]} + dedup[e.namespace+"\x00"+e.action] = e + } + entries := make([]maaAllowEntry, 0, len(dedup)) + for _, e := range dedup { + entries = append(entries, e) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].namespace != entries[j].namespace { + return entries[i].namespace < entries[j].namespace // bytewise on UTF-8 + } + return entries[i].action < entries[j].action + }) + + if len(entries) > 0xffff { + return nil, fmt.Errorf("too many allow-list entries: %d", len(entries)) + } + var cnt [2]byte + binary.BigEndian.PutUint16(cnt[:], uint16(len(entries))) + b.Write(cnt[:]) + + for _, e := range entries { + if err := maaWriteLP8(&b, []byte(e.namespace)); err != nil { + return nil, fmt.Errorf("namespace %q: %w", e.namespace, err) + } + if err := maaWriteLP8(&b, []byte(e.action)); err != nil { + return nil, fmt.Errorf("action %q: %w", e.action, err) + } + switch len(e.bodyHash) { + case 0: + b.WriteByte(0x00) + case 32: + b.WriteByte(0x01) + b.Write(e.bodyHash) + default: + return nil, fmt.Errorf("body_hash for %s.%s must be 32 bytes, got %d", e.namespace, e.action, len(e.bodyHash)) + } + } + + return ethcrypto.Keccak256(b.Bytes()), nil +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// maaWriteLP8 writes a uint8 length prefix followed by the bytes (doc 5 §1 length-prefixed strings). +func maaWriteLP8(buf *bytes.Buffer, p []byte) error { + if len(p) > 0xff { + return fmt.Errorf("length-prefixed field exceeds 255 bytes (got %d)", len(p)) + } + buf.WriteByte(byte(len(p))) + buf.Write(p) + return nil +} + +// parseFeeFlat parses a base-unit decimal string into a non-negative big.Int that fits in 256 bits. +func parseFeeFlat(s string) (*big.Int, error) { + if s == "" { + return big.NewInt(0), nil + } + v, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, fmt.Errorf("fee_flat is not a base-10 integer: %q", s) + } + if v.Sign() < 0 { + return nil, fmt.Errorf("fee_flat must be non-negative: %s", s) + } + if v.BitLen() > 256 { + return nil, fmt.Errorf("fee_flat exceeds 2^256: %s", s) + } + return v, nil +} + +// toStr normalizes a TEXT precompile input to a Go string. +func toStr(value any) (string, error) { + switch v := value.(type) { + case string: + return v, nil + case *string: + if v == nil { + return "", nil + } + return *v, nil + case []byte: + return string(v), nil + default: + return "", fmt.Errorf("expected string, got %T", value) + } +} + +// toStringSliceArray normalizes a TEXT[] precompile input to []string. +func toStringSliceArray(value any) ([]string, error) { + switch v := value.(type) { + case []string: + return v, nil + case []*string: + out := make([]string, len(v)) + for i, p := range v { + if p != nil { + out[i] = *p + } + } + return out, nil + case []any: + out := make([]string, len(v)) + for i, elem := range v { + s, err := toStr(elem) + if err != nil { + return nil, fmt.Errorf("[%d]: %w", i, err) + } + out[i] = s + } + return out, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("expected []string, got %T", value) + } +} diff --git a/extensions/tn_utils/maa_test.go b/extensions/tn_utils/maa_test.go new file mode 100644 index 00000000..b66aabfa --- /dev/null +++ b/extensions/tn_utils/maa_test.go @@ -0,0 +1,183 @@ +package tn_utils + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" +) + +// Golden vectors are frozen in 0GoalModularAgentAddresses/5RulesHash-Preimage-Spec.md §4 and were +// generated with go-ethereum keccak — the same hash these precompiles use. If these assertions +// fail, the on-chain derivation has drifted from the spec the SDKs implement. + +func hexb(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + if err != nil { + t.Fatalf("bad hex %q: %v", s, err) + } + return b +} + +func repeatByte(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} + +func TestComputeRulesHash_GoldenVectors(t *testing.T) { + // Vector A — bps fee, eth_truf, two actions (one with a body_hash). Input order is place,cancel + // to prove the canonical sort (cancel < place) is applied regardless of input order. + rhA, err := computeRulesHash( + "bps", 250, "0", "eth_truf", + []string{"main", "main"}, + []string{"ob_place_order", "ob_cancel_order"}, + [][]byte{repeatByte(0xcc, 32), nil}, + ) + if err != nil { + t.Fatalf("vector A: %v", err) + } + wantA := hexb(t, "2d43a48f5715b66c65f248aa5e1d6ac50270f9e572d0e2c03134856664cba56c") + if !bytes.Equal(rhA, wantA) { + t.Fatalf("vector A rules_hash\n got %x\nwant %x", rhA, wantA) + } + + // Vector B — flat fee 1e18, eth_usdc, empty allow-list. + rhB, err := computeRulesHash( + "flat", 0, "1000000000000000000", "eth_usdc", + []string{}, []string{}, [][]byte{}, + ) + if err != nil { + t.Fatalf("vector B: %v", err) + } + wantB := hexb(t, "2db75f81283c5f555119e0df2f9c136d59afa17edfefba6ca4c23fc0715d4599") + if !bytes.Equal(rhB, wantB) { + t.Fatalf("vector B rules_hash\n got %x\nwant %x", rhB, wantB) + } +} + +func TestComputeRulesHash_OrderIndependentAndDedup(t *testing.T) { + base, err := computeRulesHash("bps", 250, "0", "eth_truf", + []string{"main", "main"}, + []string{"ob_place_order", "ob_cancel_order"}, + [][]byte{repeatByte(0xcc, 32), nil}) + if err != nil { + t.Fatal(err) + } + + // Reversed input order must produce the same hash (canonical sort). + reordered, err := computeRulesHash("bps", 250, "0", "eth_truf", + []string{"main", "main"}, + []string{"ob_cancel_order", "ob_place_order"}, + [][]byte{nil, repeatByte(0xcc, 32)}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(base, reordered) { + t.Fatalf("reordered allow-list changed the hash:\n base %x\n reord %x", base, reordered) + } + + // A duplicate (namespace, action) must not change the hash (dedup). + deduped, err := computeRulesHash("bps", 250, "0", "eth_truf", + []string{"main", "main", "main"}, + []string{"ob_place_order", "ob_cancel_order", "ob_place_order"}, + [][]byte{repeatByte(0xcc, 32), nil, repeatByte(0xcc, 32)}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(base, deduped) { + t.Fatalf("duplicate entry changed the hash:\n base %x\n dedup %x", base, deduped) + } +} + +func TestDeriveMAAAddress_GoldenVectors(t *testing.T) { + restricted := repeatByte(0x11, 20) + unrestricted := repeatByte(0x22, 20) + + // Vector A: rules_hash from above, 32-byte salt of 0xab. + rhA := hexb(t, "2d43a48f5715b66c65f248aa5e1d6ac50270f9e572d0e2c03134856664cba56c") + addrA, err := deriveMAAAddress(restricted, unrestricted, rhA, repeatByte(0xab, 32)) + if err != nil { + t.Fatalf("vector A: %v", err) + } + wantA := hexb(t, "79ce248b31fc0d2016a175b36f79c5726b40387a") + if !bytes.Equal(addrA, wantA) { + t.Fatalf("vector A maa_address\n got %x\nwant %x", addrA, wantA) + } + if len(addrA) != 20 { + t.Fatalf("address must be 20 bytes, got %d", len(addrA)) + } + + // Vector B: empty salt. + rhB := hexb(t, "2db75f81283c5f555119e0df2f9c136d59afa17edfefba6ca4c23fc0715d4599") + addrB, err := deriveMAAAddress(restricted, unrestricted, rhB, nil) + if err != nil { + t.Fatalf("vector B: %v", err) + } + wantB := hexb(t, "3ffaf6bb0c476826d28bb7a1a3b829dabd28cab4") + if !bytes.Equal(addrB, wantB) { + t.Fatalf("vector B maa_address\n got %x\nwant %x", addrB, wantB) + } +} + +func TestDeriveMAAAddress_SaltAndKeyChangeAddress(t *testing.T) { + r := repeatByte(0x11, 20) + u := repeatByte(0x22, 20) + rh := repeatByte(0x33, 32) + + a1, err := deriveMAAAddress(r, u, rh, repeatByte(0x01, 32)) + if err != nil { + t.Fatal(err) + } + // Different salt -> different address. + a2, err := deriveMAAAddress(r, u, rh, repeatByte(0x02, 32)) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(a1, a2) { + t.Fatal("different salt produced the same address") + } + // Determinism. + a1b, err := deriveMAAAddress(r, u, rh, repeatByte(0x01, 32)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a1, a1b) { + t.Fatal("derivation is not deterministic") + } + // Swapping restricted/unrestricted -> different address (order matters). + swapped, err := deriveMAAAddress(u, r, rh, repeatByte(0x01, 32)) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(a1, swapped) { + t.Fatal("swapping restricted/unrestricted produced the same address") + } +} + +func TestDeriveMAAAddress_RejectsBadLengths(t *testing.T) { + good20 := repeatByte(0x11, 20) + good32 := repeatByte(0x33, 32) + if _, err := deriveMAAAddress(repeatByte(0x11, 19), good20, good32, nil); err == nil { + t.Fatal("expected error for 19-byte restricted") + } + if _, err := deriveMAAAddress(good20, good20, repeatByte(0x33, 31), nil); err == nil { + t.Fatal("expected error for 31-byte rules_hash") + } +} + +func TestComputeRulesHash_Validation(t *testing.T) { + if _, err := computeRulesHash("bogus", 0, "0", "eth_truf", nil, nil, nil); err == nil { + t.Fatal("expected error for bad fee_mode") + } + if _, err := computeRulesHash("bps", 0, "-1", "eth_truf", nil, nil, nil); err == nil { + t.Fatal("expected error for negative fee_flat") + } + if _, err := computeRulesHash("bps", 0, "0", "eth_truf", + []string{"main"}, []string{"a"}, [][]byte{repeatByte(0x00, 31)}); err == nil { + t.Fatal("expected error for 31-byte body_hash") + } +} diff --git a/extensions/tn_utils/precompiles.go b/extensions/tn_utils/precompiles.go index ca694e5b..591b8dab 100644 --- a/extensions/tn_utils/precompiles.go +++ b/extensions/tn_utils/precompiles.go @@ -45,6 +45,8 @@ func buildPrecompile() precompiles.Precompile { getLeaderBytesMethod(), getValidatorsMethod(), getValidatorCountMethod(), + deriveMAAAddressMethod(), + computeRulesHashMethod(), }, } } From 95a46d05489169384ac636dd283860a9f1c7edc7 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Mon, 1 Jun 2026 22:08:53 +0700 Subject: [PATCH 2/3] feat(maa): add agent-wallet rule store and audit migration --- internal/migrations/048-maa.sql | 323 +++++++++++++++++++++++++++++++ tests/streams/maa/create_test.go | 198 +++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 internal/migrations/048-maa.sql create mode 100644 tests/streams/maa/create_test.go diff --git a/internal/migrations/048-maa.sql b/internal/migrations/048-maa.sql new file mode 100644 index 00000000..60dfa45c --- /dev/null +++ b/internal/migrations/048-maa.sql @@ -0,0 +1,323 @@ +/* + * MIGRATION 048: MODULAR AGENT ADDRESSES (MAA) + * + * Rule store + append-only audit trail for fundable "agent wallets". + * Node-side SQL only — same blast radius as 031-order-book-vault.sql. No consensus change. + * + * Addresses are stored as 20-byte BYTEA internally and exchanged as 0x-prefixed hex TEXT + * at the API boundary. The MAA address and rules_hash are derived by the pure tn_utils + * precompiles (derive_maa_address / compute_rules_hash); the exact byte layout is frozen in + * 0GoalModularAgentAddresses/5RulesHash-Preimage-Spec.md and is shared with the SDKs. + * + * The rule (fee + allow-list) is set ONCE at maa_create and is IMMUTABLE thereafter, per the + * spec ("the portion ... determined at the time of rule creation"; agents act on "preset rules"). + * rules_hash commits to exactly those terms and defines the permanent address. There are NO + * setters — to change a rule, create a new MAA. The owner controls funds by withdrawing (a + * later issue), not by editing the rule. + * + * Bridge-agnostic: NO mainnet .prod.sql twin — this file calls no bridge precompile. + */ + +-- ============================================================================= +-- maa_rules: one row per agent wallet (composite identity + fee config) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS maa_rules ( + maa_address BYTEA PRIMARY KEY, -- composite identity; funds credited here; route rewrites @caller to this + rule_address BYTEA NOT NULL, -- spec "Rule Address" (== maa_address; kept for spec fidelity) + restricted_addr BYTEA NOT NULL, -- agent: creates the rule, allow-list bound + unrestricted_addr BYTEA NOT NULL, -- owner/funder: full custody + withdraw + rules_hash BYTEA NOT NULL, -- 32-byte creation-time commitment over fee + allow-list + bridge TEXT NOT NULL, -- 'eth_truf' | 'eth_usdc' (pins decimal scale) + token TEXT NOT NULL, -- 'TRUF' | 'USDC' (display; derived from bridge) + fee_mode TEXT NOT NULL, -- 'bps' | 'flat' + fee_bps INT NOT NULL DEFAULT 0, -- 0..10000 (0..100%); policy cap (if any) is enforced separately + fee_flat NUMERIC(78, 0) NOT NULL DEFAULT 0, -- base units of `bridge` + enabled BOOLEAN NOT NULL DEFAULT true, -- reserved; immutable in v1 (no revoke); always true + created_at INT8 NOT NULL, -- @height at creation + + CONSTRAINT chk_maa_rules_fee_bps CHECK (fee_bps >= 0 AND fee_bps <= 10000), + CONSTRAINT chk_maa_rules_fee_flat CHECK (fee_flat >= 0), + CONSTRAINT chk_maa_rules_fee_mode CHECK (fee_mode = 'bps' OR fee_mode = 'flat') +); + +CREATE INDEX IF NOT EXISTS idx_maa_rules_unrestricted ON maa_rules(unrestricted_addr); +CREATE INDEX IF NOT EXISTS idx_maa_rules_restricted ON maa_rules(restricted_addr); + +-- ============================================================================= +-- maa_allowed_actions: action-name references, child of maa_rules +-- ============================================================================= +CREATE TABLE IF NOT EXISTS maa_allowed_actions ( + maa_address BYTEA NOT NULL, + namespace TEXT NOT NULL, -- e.g. 'main' + action TEXT NOT NULL, -- allow-listed action name + body_hash BYTEA, -- optional action body-hash pin (MB8); NULL = unpinned + + PRIMARY KEY (maa_address, namespace, action), + FOREIGN KEY (maa_address) REFERENCES maa_rules(maa_address) ON DELETE CASCADE +); + +-- ============================================================================= +-- maa_events: append-only audit log (permanent — NOT trimmed) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS maa_events ( + id INT8 PRIMARY KEY, -- MAX(id)+1; safe under Kwil sequential block exec + maa_address BYTEA NOT NULL, + event_type TEXT NOT NULL, -- 'CREATE' (FUND/EXEC/WITHDRAW added in later issues) + actor_role TEXT NOT NULL, -- 'restricted' | 'unrestricted' + actor_addr BYTEA NOT NULL, + inner_namespace TEXT, -- nullable until exec events (later issue) + inner_action TEXT, + amount NUMERIC(78, 0), -- nullable; populated on fee/withdraw events (later issue) + tx_hash BYTEA NOT NULL, + block_height INT8 NOT NULL, + block_timestamp INT8 NOT NULL, + + FOREIGN KEY (maa_address) REFERENCES maa_rules(maa_address) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_maa_events_addr ON maa_events(maa_address); + +-- ============================================================================= +-- maa_record_event: append one audit row (private helper) +-- ============================================================================= +CREATE OR REPLACE ACTION maa_record_event( + $maa_address BYTEA, + $event_type TEXT, + $actor_role TEXT, + $actor_addr BYTEA, + $inner_namespace TEXT, + $inner_action TEXT, + $amount NUMERIC(78, 0) +) PRIVATE { + -- MAX(id)+1 is safe in Kwil (sequential block execution) — see 044-order-book-events.sql. + $next_id INT8; + for $row in SELECT COALESCE(MAX(id), 0::INT8) + 1 AS val FROM maa_events { + $next_id := $row.val; + } + + INSERT INTO maa_events ( + id, maa_address, event_type, actor_role, actor_addr, + inner_namespace, inner_action, amount, tx_hash, block_height, block_timestamp + ) VALUES ( + $next_id, $maa_address, $event_type, $actor_role, $actor_addr, + $inner_namespace, $inner_action, $amount, decode(@txid, 'hex'), @height, @block_timestamp + ); +}; + +-- ============================================================================= +-- maa_create: the RESTRICTED key signs (lifecycle step 1). The rule is set ONCE here and is +-- IMMUTABLE thereafter (spec: fee "determined at the time of rule creation"; agents act on +-- "preset rules"). There are no setters — to change a rule, create a new MAA. +-- ============================================================================= +CREATE OR REPLACE ACTION maa_create( + $unrestricted_addr TEXT, -- owner (0x-hex); the restricted signer is @caller + $salt BYTEA, -- enables several MAAs per {restricted,unrestricted} pair (may be NULL) + $bridge TEXT, + $token TEXT, + $fee_mode TEXT, + $fee_bps INT, + $fee_flat NUMERIC(78, 0), + $namespaces TEXT[], -- parallel arrays for the allow-list + $actions TEXT[], + $body_hashes BYTEA[] +) PUBLIC RETURNS (maa_address BYTEA) { + -- Restricted signer = @caller (design §6 flow 1; "whoever signs becomes the restricted party"). + $restricted_bytes BYTEA := tn_utils.get_caller_bytes(); + + -- Validate + decode the owner address (0x-hex -> 20-byte BYTEA). + if $unrestricted_addr IS NULL OR length($unrestricted_addr) != 42 + OR substring(LOWER($unrestricted_addr), 1, 2) != '0x' { + ERROR('unrestricted_addr must be a 0x-prefixed 40-hex address'); + } + $unrestricted_bytes BYTEA := decode(substring(LOWER($unrestricted_addr), 3, 40), 'hex'); + + if $restricted_bytes = $unrestricted_bytes { + ERROR('restricted and unrestricted address must differ'); + } + if $fee_mode != 'bps' AND $fee_mode != 'flat' { + ERROR('fee_mode must be bps or flat'); + } + if $fee_bps < 0 OR $fee_bps > 10000 { + ERROR('fee_bps must be between 0 and 10000 (0..100%)'); + } + + -- Parallel allow-list arrays must be equal length (NULL/empty arrays are allowed and equal). + -- Parens are required: IS DISTINCT FROM binds looser than OR (see 003-primitive-insertion.sql). + $n INT := array_length($namespaces); + if ($n IS DISTINCT FROM array_length($actions)) OR ($n IS DISTINCT FROM array_length($body_hashes)) { + ERROR('namespaces, actions and body_hashes must be the same length'); + } + + -- Commitment computed ON-CHAIN from the rule terms (never trusted from a parameter). + $rules_hash BYTEA := tn_utils.compute_rules_hash( + $fee_mode, $fee_bps, $fee_flat::text, $bridge, $namespaces, $actions, $body_hashes + ); + + -- Deterministic address. rule_address == maa_address. + $maa_address BYTEA := tn_utils.derive_maa_address( + $restricted_bytes, $unrestricted_bytes, $rules_hash, $salt + ); + + -- Reject a duplicate identity. + $exists BOOL := false; + for $row in SELECT 1 AS one FROM maa_rules WHERE maa_address = $maa_address { + $exists := true; + } + if $exists { + ERROR('an MAA already exists for this {restricted, unrestricted, rules, salt}'); + } + + INSERT INTO maa_rules ( + maa_address, rule_address, restricted_addr, unrestricted_addr, rules_hash, + bridge, token, fee_mode, fee_bps, fee_flat, enabled, created_at + ) VALUES ( + $maa_address, $maa_address, $restricted_bytes, $unrestricted_bytes, $rules_hash, + $bridge, $token, $fee_mode, $fee_bps, $fee_flat, true, @height + ); + + -- Allow-list: batch insert via parallel-array UNNEST (precedent: 033-order-book-settlement.sql:42). + INSERT INTO maa_allowed_actions (maa_address, namespace, action, body_hash) + SELECT $maa_address, t.ns, t.act, t.bh + FROM UNNEST($namespaces, $actions, $body_hashes) AS t(ns, act, bh); + + maa_record_event($maa_address, 'CREATE', 'restricted', $restricted_bytes, NULL, NULL, NULL); + + RETURN $maa_address; +}; + +-- ============================================================================= +-- Public getters (audit surface) +-- ============================================================================= +CREATE OR REPLACE ACTION maa_get_rule($maa_address BYTEA) +PUBLIC VIEW RETURNS TABLE( + maa_address TEXT, + rule_address TEXT, + restricted_addr TEXT, + unrestricted_addr TEXT, + rules_hash TEXT, + bridge TEXT, + token TEXT, + fee_mode TEXT, + fee_bps INT, + fee_flat NUMERIC(78, 0), + enabled BOOL, + created_at INT8 +) { + for $r in + SELECT + '0x' || encode(maa_address, 'hex') AS maa_a, + '0x' || encode(rule_address, 'hex') AS rule_a, + '0x' || encode(restricted_addr, 'hex') AS restr_a, + '0x' || encode(unrestricted_addr, 'hex') AS unrestr_a, + '0x' || encode(rules_hash, 'hex') AS rh, + bridge, token, fee_mode, fee_bps, fee_flat, enabled, created_at + FROM maa_rules + WHERE maa_address = $maa_address + { + RETURN NEXT $r.maa_a, $r.rule_a, $r.restr_a, $r.unrestr_a, $r.rh, + $r.bridge, $r.token, $r.fee_mode, $r.fee_bps, $r.fee_flat, $r.enabled, $r.created_at; + } +}; + +CREATE OR REPLACE ACTION maa_get_allowed_actions($maa_address BYTEA) +PUBLIC VIEW RETURNS TABLE( + namespace TEXT, + action TEXT, + body_hash TEXT +) { + for $r in + SELECT + namespace, + action, + CASE WHEN body_hash IS NULL THEN NULL ELSE '0x' || encode(body_hash, 'hex') END AS bh + FROM maa_allowed_actions + WHERE maa_address = $maa_address + ORDER BY namespace ASC, action ASC + { + RETURN NEXT $r.namespace, $r.action, $r.bh; + } +}; + +CREATE OR REPLACE ACTION maa_list_by_unrestricted($owner TEXT, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE( + maa_address TEXT, + enabled BOOL, + created_at INT8 +) { + if $limit IS NULL OR $limit <= 0 { $limit := 100; } + if $offset IS NULL OR $offset < 0 { $offset := 0; } + $owner_bytes BYTEA := decode(substring(LOWER($owner), 3, 40), 'hex'); + + for $r in + SELECT '0x' || encode(maa_address, 'hex') AS a, enabled, created_at + FROM maa_rules + WHERE unrestricted_addr = $owner_bytes + ORDER BY created_at ASC, maa_address ASC + LIMIT $limit OFFSET $offset + { + RETURN NEXT $r.a, $r.enabled, $r.created_at; + } +}; + +CREATE OR REPLACE ACTION maa_list_by_restricted($agent TEXT, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE( + maa_address TEXT, + enabled BOOL, + created_at INT8 +) { + if $limit IS NULL OR $limit <= 0 { $limit := 100; } + if $offset IS NULL OR $offset < 0 { $offset := 0; } + $agent_bytes BYTEA := decode(substring(LOWER($agent), 3, 40), 'hex'); + + for $r in + SELECT '0x' || encode(maa_address, 'hex') AS a, enabled, created_at + FROM maa_rules + WHERE restricted_addr = $agent_bytes + ORDER BY created_at ASC, maa_address ASC + LIMIT $limit OFFSET $offset + { + RETURN NEXT $r.a, $r.enabled, $r.created_at; + } +}; + +CREATE OR REPLACE ACTION maa_get_events($maa_address BYTEA, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE( + id INT8, + event_type TEXT, + actor_role TEXT, + actor_addr TEXT, + inner_namespace TEXT, + inner_action TEXT, + amount NUMERIC(78, 0), + tx_hash TEXT, + block_height INT8, + block_timestamp INT8 +) { + if $limit IS NULL OR $limit <= 0 { $limit := 100; } + if $offset IS NULL OR $offset < 0 { $offset := 0; } + + for $r in + SELECT + id, event_type, actor_role, + '0x' || encode(actor_addr, 'hex') AS actor_a, + inner_namespace, inner_action, amount, + '0x' || encode(tx_hash, 'hex') AS txh, + block_height, block_timestamp + FROM maa_events + WHERE maa_address = $maa_address + ORDER BY id ASC + LIMIT $limit OFFSET $offset + { + RETURN NEXT $r.id, $r.event_type, $r.actor_role, $r.actor_a, + $r.inner_namespace, $r.inner_action, $r.amount, $r.txh, + $r.block_height, $r.block_timestamp; + } +}; + +CREATE OR REPLACE ACTION maa_is_known($maa_address BYTEA) +PUBLIC VIEW RETURNS (known BOOL) { + for $r in SELECT 1 AS one FROM maa_rules WHERE maa_address = $maa_address { + RETURN true; + } + RETURN false; +}; diff --git a/tests/streams/maa/create_test.go b/tests/streams/maa/create_test.go new file mode 100644 index 00000000..27fd365b --- /dev/null +++ b/tests/streams/maa/create_test.go @@ -0,0 +1,198 @@ +//go:build kwiltest + +package maa + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trufnetwork/kwil-db/common" + kwilTypes "github.com/trufnetwork/kwil-db/core/types" + 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" +) + +// Two component keys used across the tests. +const ( + restrictedHex = "0x1111111111111111111111111111111111111111" + unrestrictedHex = "0x2222222222222222222222222222222222222222" +) + +func TestMAA(t *testing.T) { + testutils.RunSchemaTest(t, kwilTesting.SchemaTest{ + Name: "MAA_RuleStore", + SeedStatements: migrations.GetSeedScriptStatements(), + FunctionTests: []kwilTesting.TestFunc{ + testMAACreateMatchesGoldenVectorAndGetters(t), + testMAAValidation(t), + }, + }, testutils.GetTestOptionsWithCache()) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func repeat(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} + +func dec(t *testing.T, s string) *kwilTypes.Decimal { + t.Helper() + d, err := kwilTypes.ParseDecimalExplicit(s, 78, 0) + require.NoError(t, err) + return d +} + +// callAs invokes an action with @caller set to the given address, returning the action error (res.Error). +func callAs(ctx context.Context, platform *kwilTesting.Platform, caller util.EthereumAddress, action string, args []any, rowFn func(*common.Row) error) error { + if rowFn == nil { + rowFn = func(*common.Row) error { return nil } + } + 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, platform.DB, "", action, args, rowFn) + if err != nil { + return err + } + return res.Error +} + +// createDefaultMAA registers an MAA signed by `restricted`, returns the derived address bytes. +func createDefaultMAA(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, restricted util.EthereumAddress, feeBps int64) []byte { + t.Helper() + var addr []byte + err := callAs(ctx, platform, restricted, "maa_create", []any{ + unrestrictedHex, // $unrestricted_addr + repeat(0xab, 32), // $salt + "eth_truf", // $bridge + "TRUF", // $token + "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 { + addr = append([]byte(nil), row.Values[0].([]byte)...) + return nil + }) + require.NoError(t, err, "maa_create should succeed") + require.Len(t, addr, 20, "maa_address must be 20 bytes") + return addr +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +func testMAACreateMatchesGoldenVectorAndGetters(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) + platform.Deployer = restricted.Bytes() + + addr := createDefaultMAA(t, ctx, platform, restricted, 250) + + // The on-chain derivation MUST match the frozen golden vector A + // (5RulesHash-Preimage-Spec.md §4) — same inputs, same address. + wantA, err := hex.DecodeString("79ce248b31fc0d2016a175b36f79c5726b40387a") + require.NoError(t, err) + require.Equal(t, wantA, addr, "maa_create must reproduce golden-vector A address") + + // maa_is_known(addr) -> true + var known bool + require.NoError(t, callAs(ctx, platform, restricted, "maa_is_known", []any{addr}, + func(row *common.Row) error { known = row.Values[0].(bool); return nil })) + require.True(t, known) + + // maa_is_known(random) -> false + known = true + require.NoError(t, callAs(ctx, platform, restricted, "maa_is_known", []any{repeat(0x99, 20)}, + func(row *common.Row) error { known = row.Values[0].(bool); return nil })) + require.False(t, known, "unknown address must report not-known") + + // maa_get_rule(addr) -> field checks + var restrField, unrestrField, bridgeField, feeMode string + var feeBps int64 + var enabled bool + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_rule", []any{addr}, + func(row *common.Row) error { + restrField = row.Values[2].(string) + unrestrField = row.Values[3].(string) + bridgeField = row.Values[5].(string) + feeMode = row.Values[7].(string) + feeBps = row.Values[8].(int64) + enabled = row.Values[10].(bool) + return nil + })) + require.Equal(t, restrictedHex, restrField) + require.Equal(t, unrestrictedHex, unrestrField) + require.Equal(t, "eth_truf", bridgeField) + require.Equal(t, "bps", feeMode) + require.Equal(t, int64(250), feeBps) + require.True(t, enabled) + + // maa_get_allowed_actions(addr) -> 2 rows, canonically ordered (cancel before place) + var acts []string + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_allowed_actions", []any{addr}, + func(row *common.Row) error { acts = append(acts, row.Values[1].(string)); return nil })) + require.Equal(t, []string{"ob_cancel_order", "ob_place_order"}, acts) + + // maa_get_events(addr) -> exactly one CREATE event, actor_role restricted + var evtTypes, evtRoles []string + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_events", []any{addr, int64(100), int64(0)}, + func(row *common.Row) error { + evtTypes = append(evtTypes, row.Values[1].(string)) + evtRoles = append(evtRoles, row.Values[2].(string)) + return nil + })) + require.Equal(t, []string{"CREATE"}, evtTypes) + require.Equal(t, []string{"restricted"}, evtRoles) + return nil + } +} + +func testMAAValidation(t *testing.T) func(context.Context, *kwilTesting.Platform) error { + return func(ctx context.Context, platform *kwilTesting.Platform) error { + restricted := util.Unsafe_NewEthereumAddressFromString(restrictedHex) + owner := util.Unsafe_NewEthereumAddressFromString(unrestrictedHex) + platform.Deployer = restricted.Bytes() + + // Create the canonical MAA so the duplicate check below has something to collide with. + _ = createDefaultMAA(t, ctx, platform, restricted, 250) + + // Duplicate identity (same restricted/unrestricted/rules/salt) must be rejected. + require.Error(t, callAs(ctx, platform, restricted, "maa_create", []any{ + unrestrictedHex, repeat(0xab, 32), "eth_truf", "TRUF", "bps", int64(250), dec(t, "0"), + []string{"main", "main"}, []string{"ob_place_order", "ob_cancel_order"}, + [][]byte{repeat(0xcc, 32), nil}, + }, nil), "duplicate MAA must be rejected") + + // restricted == unrestricted must be rejected (signer is `owner`, unrestricted also owner). + require.Error(t, callAs(ctx, platform, owner, "maa_create", []any{ + unrestrictedHex, repeat(0x01, 32), "eth_truf", "TRUF", "bps", int64(0), dec(t, "0"), + []string{}, []string{}, [][]byte{}, + }, nil), "restricted == unrestricted must be rejected") + + // fee_bps out of range must be rejected. + require.Error(t, callAs(ctx, platform, restricted, "maa_create", []any{ + unrestrictedHex, repeat(0x02, 32), "eth_truf", "TRUF", "bps", int64(10001), dec(t, "0"), + []string{}, []string{}, [][]byte{}, + }, nil), "fee_bps > 10000 must be rejected") + return nil + } +} From 389b388f737ea3c3079f5a6bd53d535b5a24bbb2 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Tue, 2 Jun 2026 10:28:27 +0700 Subject: [PATCH 3/3] maa: derive token from bridge, reject duplicate actions, restrict audit deletes --- extensions/tn_utils/maa.go | 8 +++++++ extensions/tn_utils/maa_test.go | 23 ++++++++++++++++++++ internal/migrations/048-maa.sql | 37 +++++++++++++++++++++++++++++--- tests/streams/maa/create_test.go | 26 ++++++++++++++++------ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/extensions/tn_utils/maa.go b/extensions/tn_utils/maa.go index ae679b2a..65a8c45f 100644 --- a/extensions/tn_utils/maa.go +++ b/extensions/tn_utils/maa.go @@ -192,6 +192,14 @@ func computeRulesHashHandler(ctx *common.EngineContext, app *common.App, inputs // computeRulesHash builds the canonical RULES_PREIMAGE (doc 5 §1) and returns keccak256(preimage). func computeRulesHash(feeMode string, feeBps int64, feeFlatStr, bridge string, namespaces, actions []string, bodyHashes [][]byte) ([]byte, error) { + // Defensive: the three allow-list slices are indexed in lockstep below. The on-chain handler + // already equalizes them, but guard the pure function so a direct caller gets an error instead + // of an index-out-of-range panic or a silently-truncated hash. + if len(namespaces) != len(actions) || len(namespaces) != len(bodyHashes) { + return nil, fmt.Errorf("namespaces/actions/body_hashes must be equal length (%d/%d/%d)", + len(namespaces), len(actions), len(bodyHashes)) + } + var b bytes.Buffer b.WriteByte(maaRulesVersion) diff --git a/extensions/tn_utils/maa_test.go b/extensions/tn_utils/maa_test.go index b66aabfa..aefccab6 100644 --- a/extensions/tn_utils/maa_test.go +++ b/extensions/tn_utils/maa_test.go @@ -91,6 +91,21 @@ func TestComputeRulesHash_OrderIndependentAndDedup(t *testing.T) { if !bytes.Equal(base, deduped) { t.Fatalf("duplicate entry changed the hash:\n base %x\n dedup %x", base, deduped) } + + // Conflicting body_hash for a duplicate (namespace, action): the LAST occurrence wins + // (5RulesHash-Preimage-Spec.md §1 canonicalization rule 1: "last write wins for its body_hash"). + // The earlier 0xdd pin on ob_place_order is dropped in favor of the trailing 0xcc, so the result + // must equal `base` (which pins ob_place_order to 0xcc). + lastWins, err := computeRulesHash("bps", 250, "0", "eth_truf", + []string{"main", "main", "main"}, + []string{"ob_place_order", "ob_cancel_order", "ob_place_order"}, + [][]byte{repeatByte(0xdd, 32), nil, repeatByte(0xcc, 32)}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(base, lastWins) { + t.Fatalf("last-write-wins not honored for a conflicting body_hash:\n base %x\n lastWins %x", base, lastWins) + } } func TestDeriveMAAAddress_GoldenVectors(t *testing.T) { @@ -164,6 +179,9 @@ func TestDeriveMAAAddress_RejectsBadLengths(t *testing.T) { if _, err := deriveMAAAddress(repeatByte(0x11, 19), good20, good32, nil); err == nil { t.Fatal("expected error for 19-byte restricted") } + if _, err := deriveMAAAddress(good20, repeatByte(0x22, 21), good32, nil); err == nil { + t.Fatal("expected error for 21-byte unrestricted") + } if _, err := deriveMAAAddress(good20, good20, repeatByte(0x33, 31), nil); err == nil { t.Fatal("expected error for 31-byte rules_hash") } @@ -180,4 +198,9 @@ func TestComputeRulesHash_Validation(t *testing.T) { []string{"main"}, []string{"a"}, [][]byte{repeatByte(0x00, 31)}); err == nil { t.Fatal("expected error for 31-byte body_hash") } + // Mismatched parallel-slice lengths must error, not panic (index-out-of-range) or silently truncate. + if _, err := computeRulesHash("bps", 0, "0", "eth_truf", + []string{"main"}, []string{"a", "b"}, [][]byte{nil}); err == nil { + t.Fatal("expected error for mismatched namespaces/actions/body_hashes lengths") + } } diff --git a/internal/migrations/048-maa.sql b/internal/migrations/048-maa.sql index 60dfa45c..2916c455 100644 --- a/internal/migrations/048-maa.sql +++ b/internal/migrations/048-maa.sql @@ -72,7 +72,8 @@ CREATE TABLE IF NOT EXISTS maa_events ( block_height INT8 NOT NULL, block_timestamp INT8 NOT NULL, - FOREIGN KEY (maa_address) REFERENCES maa_rules(maa_address) ON DELETE CASCADE + -- RESTRICT (not CASCADE): the audit log is permanent — deleting a rule must never erase its history. + FOREIGN KEY (maa_address) REFERENCES maa_rules(maa_address) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_maa_events_addr ON maa_events(maa_address); @@ -112,8 +113,7 @@ CREATE OR REPLACE ACTION maa_record_event( CREATE OR REPLACE ACTION maa_create( $unrestricted_addr TEXT, -- owner (0x-hex); the restricted signer is @caller $salt BYTEA, -- enables several MAAs per {restricted,unrestricted} pair (may be NULL) - $bridge TEXT, - $token TEXT, + $bridge TEXT, -- 'eth_truf' | 'eth_usdc'; token is DERIVED from this (not a parameter) $fee_mode TEXT, $fee_bps INT, $fee_flat NUMERIC(78, 0), @@ -141,6 +141,18 @@ CREATE OR REPLACE ACTION maa_create( ERROR('fee_bps must be between 0 and 10000 (0..100%)'); } + -- token is DERIVED from bridge (spec 5RulesHash-Preimage-Spec.md §6.3: "bridge is committed; + -- token is not — token is derivable from bridge"). Never trust a caller-supplied token, and + -- reject any bridge we can't price (decimal scale + display token are bridge-specific). + $token TEXT; + if $bridge = 'eth_truf' { + $token := 'TRUF'; + } elseif $bridge = 'eth_usdc' { + $token := 'USDC'; + } else { + ERROR('unsupported bridge (expected eth_truf or eth_usdc)'); + } + -- Parallel allow-list arrays must be equal length (NULL/empty arrays are allowed and equal). -- Parens are required: IS DISTINCT FROM binds looser than OR (see 003-primitive-insertion.sql). $n INT := array_length($namespaces); @@ -148,6 +160,25 @@ CREATE OR REPLACE ACTION maa_create( ERROR('namespaces, actions and body_hashes must be the same length'); } + -- Reject duplicate (namespace, action) pairs. compute_rules_hash canonicalizes duplicates + -- (spec §1: dedup, last-write-wins on body_hash), but maa_allowed_actions has a + -- (maa_address, namespace, action) PRIMARY KEY, so a raw duplicate would PK-violate the insert + -- below. Fail closed here with a clear message and keep the stored allow-list 1:1 with the + -- hashed rule set (no silently-dropped body_hash pin). + $has_dup BOOL := false; + for $d in + SELECT 1 AS one + FROM UNNEST($namespaces, $actions) AS u(ns, act) + GROUP BY u.ns, u.act + HAVING COUNT(*) > 1 + LIMIT 1 + { + $has_dup := true; + } + if $has_dup { + ERROR('duplicate (namespace, action) in allow-list; each pair may appear at most once'); + } + -- Commitment computed ON-CHAIN from the rule terms (never trusted from a parameter). $rules_hash BYTEA := tn_utils.compute_rules_hash( $fee_mode, $fee_bps, $fee_flat::text, $bridge, $namespaces, $actions, $body_hashes diff --git a/tests/streams/maa/create_test.go b/tests/streams/maa/create_test.go index 27fd365b..0278ad00 100644 --- a/tests/streams/maa/create_test.go +++ b/tests/streams/maa/create_test.go @@ -79,8 +79,7 @@ func createDefaultMAA(t *testing.T, ctx context.Context, platform *kwilTesting.P err := callAs(ctx, platform, restricted, "maa_create", []any{ unrestrictedHex, // $unrestricted_addr repeat(0xab, 32), // $salt - "eth_truf", // $bridge - "TRUF", // $token + "eth_truf", // $bridge (token TRUF is derived from this) "bps", // $fee_mode feeBps, // $fee_bps dec(t, "0"), // $fee_flat @@ -126,7 +125,7 @@ func testMAACreateMatchesGoldenVectorAndGetters(t *testing.T) func(context.Conte require.False(t, known, "unknown address must report not-known") // maa_get_rule(addr) -> field checks - var restrField, unrestrField, bridgeField, feeMode string + var restrField, unrestrField, bridgeField, tokenField, feeMode string var feeBps int64 var enabled bool require.NoError(t, callAs(ctx, platform, restricted, "maa_get_rule", []any{addr}, @@ -134,6 +133,7 @@ func testMAACreateMatchesGoldenVectorAndGetters(t *testing.T) func(context.Conte restrField = row.Values[2].(string) unrestrField = row.Values[3].(string) bridgeField = row.Values[5].(string) + tokenField = row.Values[6].(string) feeMode = row.Values[7].(string) feeBps = row.Values[8].(int64) enabled = row.Values[10].(bool) @@ -142,6 +142,7 @@ func testMAACreateMatchesGoldenVectorAndGetters(t *testing.T) func(context.Conte require.Equal(t, restrictedHex, restrField) require.Equal(t, unrestrictedHex, unrestrField) require.Equal(t, "eth_truf", bridgeField) + require.Equal(t, "TRUF", tokenField, "token must be derived from bridge, not caller-supplied") require.Equal(t, "bps", feeMode) require.Equal(t, int64(250), feeBps) require.True(t, enabled) @@ -177,22 +178,35 @@ func testMAAValidation(t *testing.T) func(context.Context, *kwilTesting.Platform // Duplicate identity (same restricted/unrestricted/rules/salt) must be rejected. require.Error(t, callAs(ctx, platform, restricted, "maa_create", []any{ - unrestrictedHex, repeat(0xab, 32), "eth_truf", "TRUF", "bps", int64(250), dec(t, "0"), + unrestrictedHex, repeat(0xab, 32), "eth_truf", "bps", int64(250), dec(t, "0"), []string{"main", "main"}, []string{"ob_place_order", "ob_cancel_order"}, [][]byte{repeat(0xcc, 32), nil}, }, nil), "duplicate MAA must be rejected") // restricted == unrestricted must be rejected (signer is `owner`, unrestricted also owner). require.Error(t, callAs(ctx, platform, owner, "maa_create", []any{ - unrestrictedHex, repeat(0x01, 32), "eth_truf", "TRUF", "bps", int64(0), dec(t, "0"), + unrestrictedHex, repeat(0x01, 32), "eth_truf", "bps", int64(0), dec(t, "0"), []string{}, []string{}, [][]byte{}, }, nil), "restricted == unrestricted must be rejected") // fee_bps out of range must be rejected. require.Error(t, callAs(ctx, platform, restricted, "maa_create", []any{ - unrestrictedHex, repeat(0x02, 32), "eth_truf", "TRUF", "bps", int64(10001), dec(t, "0"), + unrestrictedHex, repeat(0x02, 32), "eth_truf", "bps", int64(10001), dec(t, "0"), []string{}, []string{}, [][]byte{}, }, nil), "fee_bps > 10000 must be rejected") + + // Duplicate (namespace, action) in the allow-list must be rejected (PK + canonical-set integrity). + require.Error(t, callAs(ctx, platform, restricted, "maa_create", []any{ + unrestrictedHex, repeat(0x03, 32), "eth_truf", "bps", int64(0), dec(t, "0"), + []string{"main", "main"}, []string{"ob_place_order", "ob_place_order"}, + [][]byte{nil, nil}, + }, nil), "duplicate (namespace, action) must be rejected") + + // Unsupported bridge must be rejected (token is derived from bridge, not caller-supplied). + require.Error(t, callAs(ctx, platform, restricted, "maa_create", []any{ + unrestrictedHex, repeat(0x04, 32), "eth_dai", "bps", int64(0), dec(t, "0"), + []string{}, []string{}, [][]byte{}, + }, nil), "unsupported bridge must be rejected") return nil } }