diff --git a/extensions/tn_utils/maa.go b/extensions/tn_utils/maa.go index 65a8c45f..1b53eea3 100644 --- a/extensions/tn_utils/maa.go +++ b/extensions/tn_utils/maa.go @@ -2,16 +2,21 @@ package tn_utils // Modular Agent Address (MAA) derivation precompiles. // -// Two pure, deterministic functions back the MAA rule store (migration 048): +// Three 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[]) +// tn_utils.compute_rules_hash(fee_mode, fee_bps, fee_flat, namespaces[], actions[], body_hashes[]) // -> keccak256(RULES_PREIMAGE) (32 bytes) -// tn_utils.derive_maa_address(restricted, unrestricted, rules_hash, salt) +// tn_utils.derive_rule_id(restricted, rules_hash, salt) +// -> keccak256(RULE_ID_PREIMAGE) (32 bytes, NOT truncated) +// tn_utils.derive_maa_address(unrestricted, restricted, rule_id) // -> 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. +// rule_id is an IDENTIFIER (the handle a funder passes to maa_join), not a fundable ETH address, so it is +// the full 32-byte keccak. maa_address IS a 20-byte ETH address (it holds funds), so it keeps the [12:]. +// The wallet is token-agnostic: the rule pins NO bridge/token (Vin §0.4), so compute_rules_hash has no +// bridge field. The exact byte layout is frozen in 0GoalModularAgentAddresses/2MAA-Plan.md §5 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" @@ -28,80 +33,140 @@ import ( ) const ( - maaRulesVersion byte = 0x01 // RULES_PREIMAGE leading version byte (doc 5 §1) - maaAddrVersion byte = 0x01 // ADDRESS_PREIMAGE leading version byte (doc 5 §2) + maaRulesVersion byte = 0x01 // RULES_PREIMAGE leading version byte (plan §5.1) + maaRuleIDVersion byte = 0x01 // RULE_ID_PREIMAGE leading version byte (plan §5.2) + maaAddrVersion byte = 0x01 // ADDRESS_PREIMAGE leading version byte (plan §5.3) ) // --------------------------------------------------------------------------- -// derive_maa_address +// derive_rule_id // --------------------------------------------------------------------------- -func deriveMAAAddressMethod() precompiles.Method { +func deriveRuleIDMethod() precompiles.Method { return precompiles.Method{ - Name: "derive_maa_address", + Name: "derive_rule_id", 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), + precompiles.NewPrecompileValue("rule_id", types.ByteaType, false), }, }, - Handler: deriveMAAAddressHandler, + Handler: deriveRuleIDHandler, } } -func deriveMAAAddressHandler(ctx *common.EngineContext, app *common.App, inputs []any, resultFn func([]any) error) error { +func deriveRuleIDHandler(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]) + rulesHash, err := toByteSliceAllowNil(inputs[1]) if err != nil { return fmt.Errorf("rules_hash: %w", err) } - salt, err := toByteSliceAllowNil(inputs[3]) + salt, err := toByteSliceAllowNil(inputs[2]) if err != nil { return fmt.Errorf("salt: %w", err) } - addr, err := deriveMAAAddress(restricted, unrestricted, rulesHash, salt) + id, err := deriveRuleID(restricted, rulesHash, salt) if err != nil { return err } - return resultFn([]any{addr}) + return resultFn([]any{id}) } -// 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) { +// deriveRuleID builds RULE_ID_PREIMAGE (plan §5.2) and returns the FULL 32-byte keccak256 — the rule_id +// is an identifier, not an address, so it is NOT truncated to 20 bytes. +func deriveRuleID(restricted, 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) + // RULE_ID_PREIMAGE = version ‖ restricted ‖ rules_hash ‖ salt (salt last/variable) var buf bytes.Buffer - buf.WriteByte(maaAddrVersion) + buf.WriteByte(maaRuleIDVersion) buf.Write(restricted) - buf.Write(unrestricted) buf.Write(rulesHash) buf.Write(salt) + return ethcrypto.Keccak256(buf.Bytes()), nil // 32 bytes, untruncated +} + +// --------------------------------------------------------------------------- +// 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("unrestricted", types.ByteaType, false), + precompiles.NewPrecompileValue("restricted", types.ByteaType, false), + precompiles.NewPrecompileValue("rule_id", types.ByteaType, false), + }, + 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 { + unrestricted, err := toByteSliceAllowNil(inputs[0]) + if err != nil { + return fmt.Errorf("unrestricted: %w", err) + } + restricted, err := toByteSliceAllowNil(inputs[1]) + if err != nil { + return fmt.Errorf("restricted: %w", err) + } + ruleID, err := toByteSliceAllowNil(inputs[2]) + if err != nil { + return fmt.Errorf("rule_id: %w", err) + } + + addr, err := deriveMAAAddress(unrestricted, restricted, ruleID) + if err != nil { + return err + } + return resultFn([]any{addr}) +} + +// deriveMAAAddress builds the canonical ADDRESS_PREIMAGE (plan §5.3) from the composite +// (unrestricted, restricted, rule_id) and returns the low 20 bytes of keccak256(preimage) — +// the Ethereum-style MAA address that actually holds funds. +func deriveMAAAddress(unrestricted, restricted, ruleID []byte) ([]byte, error) { + if len(unrestricted) != 20 { + return nil, fmt.Errorf("unrestricted must be 20 bytes, got %d", len(unrestricted)) + } + if len(restricted) != 20 { + return nil, fmt.Errorf("restricted must be 20 bytes, got %d", len(restricted)) + } + if len(ruleID) != 32 { + return nil, fmt.Errorf("rule_id must be 32 bytes, got %d", len(ruleID)) + } + + // ADDRESS_PREIMAGE = version ‖ unrestricted ‖ restricted ‖ rule_id (Vin's verbatim order) + var buf bytes.Buffer + buf.WriteByte(maaAddrVersion) + buf.Write(unrestricted) + buf.Write(restricted) + buf.Write(ruleID) + full := ethcrypto.Keccak256(buf.Bytes()) // 32 bytes out := make([]byte, 20) copy(out, full[12:32]) // low 20 bytes @@ -120,7 +185,6 @@ func computeRulesHashMethod() precompiles.Method { 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 @@ -154,21 +218,17 @@ func computeRulesHashHandler(ctx *common.EngineContext, app *common.App, inputs 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]) + namespaces, err := toStringSliceArray(inputs[3]) if err != nil { return fmt.Errorf("namespaces: %w", err) } - actions, err := toStringSliceArray(inputs[5]) + actions, err := toStringSliceArray(inputs[4]) if err != nil { return fmt.Errorf("actions: %w", err) } var bodyHashes [][]byte - if inputs[6] != nil { - bodyHashes, err = toByteSliceArray(inputs[6]) + if inputs[5] != nil { + bodyHashes, err = toByteSliceArray(inputs[5]) if err != nil { return fmt.Errorf("body_hashes: %w", err) } @@ -183,15 +243,16 @@ func computeRulesHashHandler(ctx *common.EngineContext, app *common.App, inputs len(namespaces), len(actions), len(bodyHashes)) } - hash, err := computeRulesHash(feeMode, feeBps, feeFlatStr, bridge, namespaces, actions, bodyHashes) + hash, err := computeRulesHash(feeMode, feeBps, feeFlatStr, 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) { +// computeRulesHash builds the canonical RULES_PREIMAGE (plan §5.1, token-agnostic: NO bridge field) and +// returns keccak256(preimage). +func computeRulesHash(feeMode string, feeBps int64, feeFlatStr 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. @@ -228,11 +289,7 @@ func computeRulesHash(feeMode string, feeBps int64, feeFlatStr, bridge string, n 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. + // Canonicalize: dedup by (namespace, action), sort bytewise on raw UTF-8. dedup := make(map[string]maaAllowEntry, len(namespaces)) for i := range namespaces { e := maaAllowEntry{namespace: namespaces[i], action: actions[i], bodyHash: bodyHashes[i]} @@ -281,7 +338,7 @@ func computeRulesHash(feeMode string, feeBps int64, feeFlatStr, bridge string, n // helpers // --------------------------------------------------------------------------- -// maaWriteLP8 writes a uint8 length prefix followed by the bytes (doc 5 §1 length-prefixed strings). +// maaWriteLP8 writes a uint8 length prefix followed by the bytes (plan §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)) diff --git a/extensions/tn_utils/maa_test.go b/extensions/tn_utils/maa_test.go index aefccab6..53251134 100644 --- a/extensions/tn_utils/maa_test.go +++ b/extensions/tn_utils/maa_test.go @@ -7,9 +7,11 @@ import ( "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. +// Golden vectors are frozen in 0GoalModularAgentAddresses/2MAA-Plan.md §5.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. Two-step + token-agnostic: rules_hash carries +// NO bridge; rule_id is the full 32-byte keccak (an identifier, untruncated); maa_address is the 20-byte +// keccak[12:] of the composite (unrestricted, restricted, rule_id). func hexb(t *testing.T, s string) []byte { t.Helper() @@ -29,10 +31,10 @@ func repeatByte(b byte, n int) []byte { } 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. + // Vector A — bps fee, 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. NO bridge. rhA, err := computeRulesHash( - "bps", 250, "0", "eth_truf", + "bps", 250, "0", []string{"main", "main"}, []string{"ob_place_order", "ob_cancel_order"}, [][]byte{repeatByte(0xcc, 32), nil}, @@ -40,27 +42,27 @@ func TestComputeRulesHash_GoldenVectors(t *testing.T) { if err != nil { t.Fatalf("vector A: %v", err) } - wantA := hexb(t, "2d43a48f5715b66c65f248aa5e1d6ac50270f9e572d0e2c03134856664cba56c") + wantA := hexb(t, "df0555d336647bec5e9fe1f6f613086bddf53548b67c52393aef6db4cbef062d") 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. + // Vector B — flat fee 1e18, empty allow-list. rhB, err := computeRulesHash( - "flat", 0, "1000000000000000000", "eth_usdc", + "flat", 0, "1000000000000000000", []string{}, []string{}, [][]byte{}, ) if err != nil { t.Fatalf("vector B: %v", err) } - wantB := hexb(t, "2db75f81283c5f555119e0df2f9c136d59afa17edfefba6ca4c23fc0715d4599") + wantB := hexb(t, "0b1edb0ad70fb94287e50c7b3deaea7bba4e500c4ae6a764ed9021faf091274a") 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", + base, err := computeRulesHash("bps", 250, "0", []string{"main", "main"}, []string{"ob_place_order", "ob_cancel_order"}, [][]byte{repeatByte(0xcc, 32), nil}) @@ -69,7 +71,7 @@ func TestComputeRulesHash_OrderIndependentAndDedup(t *testing.T) { } // Reversed input order must produce the same hash (canonical sort). - reordered, err := computeRulesHash("bps", 250, "0", "eth_truf", + reordered, err := computeRulesHash("bps", 250, "0", []string{"main", "main"}, []string{"ob_cancel_order", "ob_place_order"}, [][]byte{nil, repeatByte(0xcc, 32)}) @@ -81,7 +83,7 @@ func TestComputeRulesHash_OrderIndependentAndDedup(t *testing.T) { } // A duplicate (namespace, action) must not change the hash (dedup). - deduped, err := computeRulesHash("bps", 250, "0", "eth_truf", + deduped, err := computeRulesHash("bps", 250, "0", []string{"main", "main", "main"}, []string{"ob_place_order", "ob_cancel_order", "ob_place_order"}, [][]byte{repeatByte(0xcc, 32), nil, repeatByte(0xcc, 32)}) @@ -92,11 +94,10 @@ func TestComputeRulesHash_OrderIndependentAndDedup(t *testing.T) { 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", + // Conflicting body_hash for a duplicate (namespace, action): the LAST occurrence wins (plan §5.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`. + lastWins, err := computeRulesHash("bps", 250, "0", []string{"main", "main", "main"}, []string{"ob_place_order", "ob_cancel_order", "ob_place_order"}, [][]byte{repeatByte(0xdd, 32), nil, repeatByte(0xcc, 32)}) @@ -108,17 +109,75 @@ func TestComputeRulesHash_OrderIndependentAndDedup(t *testing.T) { } } -func TestDeriveMAAAddress_GoldenVectors(t *testing.T) { +func TestComputeRulesHash_Validation(t *testing.T) { + if _, err := computeRulesHash("bogus", 0, "0", nil, nil, nil); err == nil { + t.Fatal("expected error for bad fee_mode") + } + if _, err := computeRulesHash("bps", 0, "-1", nil, nil, nil); err == nil { + t.Fatal("expected error for negative fee_flat") + } + if _, err := computeRulesHash("bps", 0, "0", + []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 or silently truncate. + if _, err := computeRulesHash("bps", 0, "0", + []string{"main"}, []string{"a", "b"}, [][]byte{nil}); err == nil { + t.Fatal("expected error for mismatched namespaces/actions/body_hashes lengths") + } +} + +func TestDeriveRuleID_GoldenVectors(t *testing.T) { restricted := repeatByte(0x11, 20) + + // Vector A: rules_hash from above, 32-byte salt of 0xab. rule_id is the FULL 32-byte keccak. + rhA := hexb(t, "df0555d336647bec5e9fe1f6f613086bddf53548b67c52393aef6db4cbef062d") + idA, err := deriveRuleID(restricted, rhA, repeatByte(0xab, 32)) + if err != nil { + t.Fatalf("vector A: %v", err) + } + wantA := hexb(t, "a0b517da759b794e2484dc8b9dba8f5211a53dcdf26448f19c7c68699ff7bcf1") + if !bytes.Equal(idA, wantA) { + t.Fatalf("vector A rule_id\n got %x\nwant %x", idA, wantA) + } + if len(idA) != 32 { + t.Fatalf("rule_id must be 32 bytes (untruncated), got %d", len(idA)) + } + + // Vector B: empty salt. + rhB := hexb(t, "0b1edb0ad70fb94287e50c7b3deaea7bba4e500c4ae6a764ed9021faf091274a") + idB, err := deriveRuleID(restricted, rhB, nil) + if err != nil { + t.Fatalf("vector B: %v", err) + } + wantB := hexb(t, "21f40fbf0fd537f85d283cf7b5f2fe8602c1f4b910aad96ad2dad9f6e82b1ca5") + if !bytes.Equal(idB, wantB) { + t.Fatalf("vector B rule_id\n got %x\nwant %x", idB, wantB) + } +} + +func TestDeriveRuleID_RejectsBadLengths(t *testing.T) { + good20 := repeatByte(0x11, 20) + good32 := repeatByte(0x33, 32) + if _, err := deriveRuleID(repeatByte(0x11, 19), good32, nil); err == nil { + t.Fatal("expected error for 19-byte restricted") + } + if _, err := deriveRuleID(good20, repeatByte(0x33, 31), nil); err == nil { + t.Fatal("expected error for 31-byte rules_hash") + } +} + +func TestDeriveMAAAddress_GoldenVectors(t *testing.T) { unrestricted := repeatByte(0x22, 20) + restricted := repeatByte(0x11, 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)) + // Vector A: composite (unrestricted, restricted, rule_id_A). + idA := hexb(t, "a0b517da759b794e2484dc8b9dba8f5211a53dcdf26448f19c7c68699ff7bcf1") + addrA, err := deriveMAAAddress(unrestricted, restricted, idA) if err != nil { t.Fatalf("vector A: %v", err) } - wantA := hexb(t, "79ce248b31fc0d2016a175b36f79c5726b40387a") + wantA := hexb(t, "84da4dbca14d429c719d65a0bb76bd7fa3c5c349") if !bytes.Equal(addrA, wantA) { t.Fatalf("vector A maa_address\n got %x\nwant %x", addrA, wantA) } @@ -126,81 +185,71 @@ func TestDeriveMAAAddress_GoldenVectors(t *testing.T) { 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) + // Vector B: rule_id_B. + idB := hexb(t, "21f40fbf0fd537f85d283cf7b5f2fe8602c1f4b910aad96ad2dad9f6e82b1ca5") + addrB, err := deriveMAAAddress(unrestricted, restricted, idB) if err != nil { t.Fatalf("vector B: %v", err) } - wantB := hexb(t, "3ffaf6bb0c476826d28bb7a1a3b829dabd28cab4") + wantB := hexb(t, "cb009e348c3ad795aa6d7d81177f0daee4583128") 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) +func TestDeriveMAAAddress_KeyChangesAddressAndDeterministic(t *testing.T) { u := repeatByte(0x22, 20) - rh := repeatByte(0x33, 32) + r := repeatByte(0x11, 20) + id := repeatByte(0x33, 32) - a1, err := deriveMAAAddress(r, u, rh, repeatByte(0x01, 32)) + a1, err := deriveMAAAddress(u, r, id) if err != nil { t.Fatal(err) } - // Different salt -> different address. - a2, err := deriveMAAAddress(r, u, rh, repeatByte(0x02, 32)) + // Determinism. + a1b, err := deriveMAAAddress(u, r, id) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a1, a1b) { + t.Fatal("derivation is not deterministic") + } + // Different funder -> different address (the funder disambiguates MAAs of one rule). + a2, err := deriveMAAAddress(repeatByte(0x44, 20), r, id) if err != nil { t.Fatal(err) } if bytes.Equal(a1, a2) { - t.Fatal("different salt produced the same address") + t.Fatal("different unrestricted produced the same address") } - // Determinism. - a1b, err := deriveMAAAddress(r, u, rh, repeatByte(0x01, 32)) + // Different rule_id -> different address. + a3, err := deriveMAAAddress(u, r, repeatByte(0x55, 32)) if err != nil { t.Fatal(err) } - if !bytes.Equal(a1, a1b) { - t.Fatal("derivation is not deterministic") + if bytes.Equal(a1, a3) { + t.Fatal("different rule_id produced the same address") } - // Swapping restricted/unrestricted -> different address (order matters). - swapped, err := deriveMAAAddress(u, r, rh, repeatByte(0x01, 32)) + // Swapping unrestricted/restricted -> different address (order matters). + swapped, err := deriveMAAAddress(r, u, id) if err != nil { t.Fatal(err) } if bytes.Equal(a1, swapped) { - t.Fatal("swapping restricted/unrestricted produced the same address") + t.Fatal("swapping unrestricted/restricted produced the same address") } } func TestDeriveMAAAddress_RejectsBadLengths(t *testing.T) { - good20 := repeatByte(0x11, 20) + good20 := repeatByte(0x22, 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, 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") - } -} - -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 := deriveMAAAddress(repeatByte(0x22, 19), good20, good32); err == nil { + t.Fatal("expected error for 19-byte unrestricted") } - 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") + if _, err := deriveMAAAddress(good20, repeatByte(0x11, 21), good32); err == nil { + t.Fatal("expected error for 21-byte restricted") } - // 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") + if _, err := deriveMAAAddress(good20, good20, repeatByte(0x33, 31)); err == nil { + t.Fatal("expected error for 31-byte rule_id") } } diff --git a/extensions/tn_utils/precompiles.go b/extensions/tn_utils/precompiles.go index 591b8dab..d48d690e 100644 --- a/extensions/tn_utils/precompiles.go +++ b/extensions/tn_utils/precompiles.go @@ -45,8 +45,9 @@ func buildPrecompile() precompiles.Precompile { getLeaderBytesMethod(), getValidatorsMethod(), getValidatorCountMethod(), - deriveMAAAddressMethod(), computeRulesHashMethod(), + deriveRuleIDMethod(), + deriveMAAAddressMethod(), }, } } diff --git a/internal/migrations/048-maa.sql b/internal/migrations/048-maa.sql index 2916c455..335e5ec2 100644 --- a/internal/migrations/048-maa.sql +++ b/internal/migrations/048-maa.sql @@ -1,68 +1,87 @@ /* * MIGRATION 048: MODULAR AGENT ADDRESSES (MAA) * - * Rule store + append-only audit trail for fundable "agent wallets". + * Rule store + MAA-instance lookup + append-only audit 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. + * TWO addresses (spec 0MainGoal.md): a RULE_ID identifies a rule set; a funder JOINS it to get a + * distinct MAA. rule_id is a 32-byte CONTENT-HASH IDENTIFIER (not an ETH address — never funded + * directly); maa_address is a real 20-byte ETH address that holds funds. Both are derived by the pure + * tn_utils precompiles (compute_rules_hash / derive_rule_id / derive_maa_address); the exact byte layout + * is frozen in 0GoalModularAgentAddresses/2MAA-Plan.md §5 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. + * Token-agnostic (Vin §0.4): the rule pins NO bridge/token — the wallet accepts all bridged tokens, so + * this file calls no bridge precompile (NO mainnet .prod.sql twin). * - * Bridge-agnostic: NO mainnet .prod.sql twin — this file calls no bridge precompile. + * The rule (fee + allow-list) is set ONCE at maa_create_rule and is IMMUTABLE thereafter (Vin §0.5). + * rules_hash commits to exactly those terms and (via rule_id) defines the addresses. There are NO + * setters — to change a rule, create a new one. The owner controls funds by withdrawing (a later issue), + * not by editing the rule. + * + * Two write actions, BOTH fund-free: + * maa_create_rule -- the RESTRICTED creator registers a rule, returns rule_id. + * maa_join -- a funder (UNRESTRICTED) joins a rule_id, returns the derived MAA address. + * Funding is a separate transfer to the MAA address (a later issue). */ -- ============================================================================= --- maa_rules: one row per agent wallet (composite identity + fee config) +-- maa_rules: one row per RULE. Keyed by rule_id (a 32-byte identifier). +-- Reusable by unlimited funders -> many MAAs. -- ============================================================================= 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 + rule_id BYTEA PRIMARY KEY, -- 32-byte id: keccak(version‖restricted‖rules_hash‖salt) (untruncated) + restricted_addr BYTEA NOT NULL, -- agent: creates the rule, allow-list bound + rules_hash BYTEA NOT NULL, -- 32-byte creation-time commitment over fee + allow-list + salt BYTEA, -- rule nonce: lets one creator have several distinct rules (may be NULL) + fee_mode TEXT NOT NULL, -- 'bps' | 'flat' + fee_bps INT NOT NULL DEFAULT 0, -- 0..10000 (0..100%; 10000 = 100%). No policy cap (Vin §0.3). + fee_flat NUMERIC(78, 0) NOT NULL DEFAULT 0, -- base units; denomination resolved at withdrawal + 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); +CREATE INDEX IF NOT EXISTS idx_maa_rules_restricted ON maa_rules(restricted_addr); -- ============================================================================= --- maa_allowed_actions: action-name references, child of maa_rules +-- maa_allowed_actions: action-name references, child of maa_rules (by rule_id). -- ============================================================================= 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 + rule_id 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 + PRIMARY KEY (rule_id, namespace, action), + FOREIGN KEY (rule_id) REFERENCES maa_rules(rule_id) ON DELETE CASCADE ); -- ============================================================================= --- maa_events: append-only audit log (permanent — NOT trimmed) +-- maa_instances: one row per joined MAA. Created by maa_join (fund-free). +-- THE lookup table: maa_address -> {rule_id, restricted (via rule), unrestricted}. +-- ============================================================================= +CREATE TABLE IF NOT EXISTS maa_instances ( + maa_address BYTEA PRIMARY KEY, -- 20-byte ETH addr: keccak(version‖unrestricted‖restricted‖rule_id)[12:] + rule_id BYTEA NOT NULL, -- FK to maa_rules + unrestricted_addr BYTEA NOT NULL, -- owner / funder (bound at maa_join) + created_at INT8 NOT NULL, + + FOREIGN KEY (rule_id) REFERENCES maa_rules(rule_id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_maa_inst_unrestricted ON maa_instances(unrestricted_addr); +CREATE INDEX IF NOT EXISTS idx_maa_inst_rule ON maa_instances(rule_id); + +-- ============================================================================= +-- 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) + rule_id BYTEA NOT NULL, + maa_address BYTEA, -- nullable: rule-level events (CREATE_RULE) have no MAA + event_type TEXT NOT NULL, -- 'CREATE_RULE' | 'JOIN' (FUND/EXEC/WITHDRAW in later issues) actor_role TEXT NOT NULL, -- 'restricted' | 'unrestricted' actor_addr BYTEA NOT NULL, inner_namespace TEXT, -- nullable until exec events (later issue) @@ -73,15 +92,17 @@ CREATE TABLE IF NOT EXISTS maa_events ( block_timestamp INT8 NOT NULL, -- 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 + FOREIGN KEY (rule_id) REFERENCES maa_rules(rule_id) ON DELETE RESTRICT ); -CREATE INDEX IF NOT EXISTS idx_maa_events_addr ON maa_events(maa_address); +CREATE INDEX IF NOT EXISTS idx_maa_events_rule ON maa_events(rule_id); +CREATE INDEX IF NOT EXISTS idx_maa_events_maa ON maa_events(maa_address); -- ============================================================================= -- maa_record_event: append one audit row (private helper) -- ============================================================================= CREATE OR REPLACE ACTION maa_record_event( + $rule_id BYTEA, $maa_address BYTEA, $event_type TEXT, $actor_role TEXT, @@ -97,60 +118,40 @@ CREATE OR REPLACE ACTION maa_record_event( } INSERT INTO maa_events ( - id, maa_address, event_type, actor_role, actor_addr, + id, rule_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, + $next_id, $rule_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. +-- maa_create_rule: the RESTRICTED key signs (lifecycle step 1). The rule is set ONCE here and is +-- IMMUTABLE thereafter (Vin §0.5). No setters. The unrestricted owner is NOT a parameter — it is bound +-- later, at maa_join. FUND-FREE. -- ============================================================================= -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, -- 'eth_truf' | 'eth_usdc'; token is DERIVED from this (not a parameter) - $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"). +CREATE OR REPLACE ACTION maa_create_rule( + $salt BYTEA, -- rule nonce (enables several distinct rules per creator; may be NULL) + $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 (rule_id BYTEA) { + -- Creator = @caller = restricted (design 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%)'); + ERROR('fee_bps must be between 0 and 10000 (10000 = 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)'); + -- Friendly action-level guard mirroring chk_maa_rules_fee_flat (a raw CHECK violation is opaque). + if $fee_flat < 0::NUMERIC(78, 0) { + ERROR('fee_flat must be >= 0'); } -- Parallel allow-list arrays must be equal length (NULL/empty arrays are allowed and equal). @@ -160,11 +161,10 @@ 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). + -- Reject duplicate (namespace, action) pairs. compute_rules_hash canonicalizes duplicates (plan §5.1: + -- dedup, last-write-wins on body_hash), but maa_allowed_actions has a (rule_id, 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. $has_dup BOOL := false; for $d in SELECT 1 AS one @@ -179,78 +179,108 @@ CREATE OR REPLACE ACTION maa_create( 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). + -- Commitment computed ON-CHAIN from the rule terms (never trusted from a parameter). NO bridge param. $rules_hash BYTEA := tn_utils.compute_rules_hash( - $fee_mode, $fee_bps, $fee_flat::text, $bridge, $namespaces, $actions, $body_hashes + $fee_mode, $fee_bps, $fee_flat::text, $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 - ); + -- rule_id is the 32-byte content-hash IDENTIFIER (untruncated). + $rule_id BYTEA := tn_utils.derive_rule_id($restricted_bytes, $rules_hash, $salt); - -- Reject a duplicate identity. + -- Reject a duplicate rule identity. $exists BOOL := false; - for $row in SELECT 1 AS one FROM maa_rules WHERE maa_address = $maa_address { + for $row in SELECT 1 AS one FROM maa_rules WHERE rule_id = $rule_id { $exists := true; } if $exists { - ERROR('an MAA already exists for this {restricted, unrestricted, rules, salt}'); + ERROR('a rule already exists for this {restricted, 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 + rule_id, restricted_addr, rules_hash, salt, fee_mode, fee_bps, fee_flat, created_at ) VALUES ( - $maa_address, $maa_address, $restricted_bytes, $unrestricted_bytes, $rules_hash, - $bridge, $token, $fee_mode, $fee_bps, $fee_flat, true, @height + $rule_id, $restricted_bytes, $rules_hash, $salt, $fee_mode, $fee_bps, $fee_flat, @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 + INSERT INTO maa_allowed_actions (rule_id, namespace, action, body_hash) + SELECT $rule_id, 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); + maa_record_event($rule_id, NULL, 'CREATE_RULE', 'restricted', $restricted_bytes, NULL, NULL, NULL); + + RETURN $rule_id; +}; + +-- ============================================================================= +-- maa_join: a funder (the UNRESTRICTED key) joins a rule_id (lifecycle step 2). Binds the caller as the +-- unrestricted owner, derives the deterministic MAA address, and records the instance. FUND-FREE — funding +-- is a separate transfer to the returned MAA address (a later issue). SDK name: joinAgentAddress(ruleId). +-- ============================================================================= +CREATE OR REPLACE ACTION maa_join($rule_id BYTEA) PUBLIC RETURNS (maa_address BYTEA) { + $unrestricted_bytes BYTEA := tn_utils.get_caller_bytes(); + + -- Look up the rule's restricted creator (also asserts the rule_id exists). + $restricted_bytes BYTEA; + $found BOOL := false; + for $r in SELECT restricted_addr FROM maa_rules WHERE rule_id = $rule_id { + $restricted_bytes := $r.restricted_addr; + $found := true; + } + if !$found { + ERROR('unknown rule_id'); + } + if $unrestricted_bytes = $restricted_bytes { + ERROR('unrestricted must differ from the rule creator'); + } + + -- Derive the real 20-byte MAA address from the composite (unrestricted, restricted, rule_id). + $maa_address BYTEA := tn_utils.derive_maa_address($unrestricted_bytes, $restricted_bytes, $rule_id); + + -- Reject a duplicate join (this funder already has an MAA for this rule). + $exists BOOL := false; + for $row in SELECT 1 AS one FROM maa_instances WHERE maa_address = $maa_address { + $exists := true; + } + if $exists { + ERROR('this funder has already joined this rule (MAA exists)'); + } + + INSERT INTO maa_instances (maa_address, rule_id, unrestricted_addr, created_at) + VALUES ($maa_address, $rule_id, $unrestricted_bytes, @height); + + maa_record_event($rule_id, $maa_address, 'JOIN', 'unrestricted', $unrestricted_bytes, NULL, NULL, NULL); RETURN $maa_address; }; -- ============================================================================= --- Public getters (audit surface) +-- Public getters (audit / transparency surface) -- ============================================================================= -CREATE OR REPLACE ACTION maa_get_rule($maa_address BYTEA) +CREATE OR REPLACE ACTION maa_get_rule($rule_id BYTEA) PUBLIC VIEW RETURNS TABLE( - maa_address TEXT, - rule_address TEXT, + rule_id 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 + '0x' || encode(rule_id, 'hex') AS rid, + '0x' || encode(restricted_addr, 'hex') AS restr_a, + '0x' || encode(rules_hash, 'hex') AS rh, + fee_mode, fee_bps, fee_flat, created_at FROM maa_rules - WHERE maa_address = $maa_address + WHERE rule_id = $rule_id { - 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; + RETURN NEXT $r.rid, $r.restr_a, $r.rh, $r.fee_mode, $r.fee_bps, $r.fee_flat, $r.created_at; } }; -CREATE OR REPLACE ACTION maa_get_allowed_actions($maa_address BYTEA) +CREATE OR REPLACE ACTION maa_get_allowed_actions($rule_id BYTEA) PUBLIC VIEW RETURNS TABLE( namespace TEXT, action TEXT, @@ -262,58 +292,116 @@ PUBLIC VIEW RETURNS TABLE( 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 + WHERE rule_id = $rule_id 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) +-- maa_get_instance: the primary explorer/wallet lookup — MAA address -> both keys + rule set (via rule_id). +CREATE OR REPLACE ACTION maa_get_instance($maa_address BYTEA) PUBLIC VIEW RETURNS TABLE( maa_address TEXT, - enabled BOOL, + rule_id TEXT, + restricted_addr TEXT, + unrestricted_addr TEXT, + created_at INT8 +) { + for $r in + SELECT + '0x' || encode(i.maa_address, 'hex') AS maa_a, + '0x' || encode(i.rule_id, 'hex') AS rid, + '0x' || encode(r.restricted_addr, 'hex') AS restr_a, + '0x' || encode(i.unrestricted_addr, 'hex') AS unrestr_a, + i.created_at AS ca + FROM maa_instances i + JOIN maa_rules r ON r.rule_id = i.rule_id + WHERE i.maa_address = $maa_address + { + RETURN NEXT $r.maa_a, $r.rid, $r.restr_a, $r.unrestr_a, $r.ca; + } +}; + +CREATE OR REPLACE ACTION maa_list_by_restricted($agent TEXT, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE( + rule_id TEXT, 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'); + -- Normalize $agent: accept with/without 0x prefix; require 40 hex chars (decode rejects non-hex digits). + $agent_hex TEXT := LOWER($agent); + if substring($agent_hex, 1, 2) = '0x' { $agent_hex := substring($agent_hex, 3, length($agent_hex)); } + if length($agent_hex) != 40 { ERROR('agent must be a 20-byte hex address (40 hex chars, optional 0x prefix)'); } + $agent_bytes BYTEA := decode($agent_hex, 'hex'); for $r in - SELECT '0x' || encode(maa_address, 'hex') AS a, enabled, created_at + SELECT '0x' || encode(rule_id, 'hex') AS rid, created_at FROM maa_rules + WHERE restricted_addr = $agent_bytes + ORDER BY created_at ASC, rule_id ASC + LIMIT $limit OFFSET $offset + { + RETURN NEXT $r.rid, $r.created_at; + } +}; + +CREATE OR REPLACE ACTION maa_list_by_unrestricted($owner TEXT, $limit INT, $offset INT) +PUBLIC VIEW RETURNS TABLE( + maa_address TEXT, + rule_id TEXT, + created_at INT8 +) { + if $limit IS NULL OR $limit <= 0 { $limit := 100; } + if $offset IS NULL OR $offset < 0 { $offset := 0; } + -- Normalize $owner: accept with/without 0x prefix; require 40 hex chars (decode rejects non-hex digits). + $owner_hex TEXT := LOWER($owner); + if substring($owner_hex, 1, 2) = '0x' { $owner_hex := substring($owner_hex, 3, length($owner_hex)); } + if length($owner_hex) != 40 { ERROR('owner must be a 20-byte hex address (40 hex chars, optional 0x prefix)'); } + $owner_bytes BYTEA := decode($owner_hex, 'hex'); + + for $r in + SELECT + '0x' || encode(maa_address, 'hex') AS maa_a, + '0x' || encode(rule_id, 'hex') AS rid, + created_at + FROM maa_instances 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; + RETURN NEXT $r.maa_a, $r.rid, $r.created_at; } }; -CREATE OR REPLACE ACTION maa_list_by_restricted($agent TEXT, $limit INT, $offset INT) +CREATE OR REPLACE ACTION maa_list_instances_by_rule($rule_id BYTEA, $limit INT, $offset INT) PUBLIC VIEW RETURNS TABLE( maa_address TEXT, - enabled BOOL, + unrestricted_addr TEXT, 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 + SELECT + '0x' || encode(maa_address, 'hex') AS maa_a, + '0x' || encode(unrestricted_addr, 'hex') AS unrestr_a, + created_at + FROM maa_instances + WHERE rule_id = $rule_id ORDER BY created_at ASC, maa_address ASC LIMIT $limit OFFSET $offset { - RETURN NEXT $r.a, $r.enabled, $r.created_at; + RETURN NEXT $r.maa_a, $r.unrestr_a, $r.created_at; } }; -CREATE OR REPLACE ACTION maa_get_events($maa_address BYTEA, $limit INT, $offset INT) +CREATE OR REPLACE ACTION maa_get_events($rule_id BYTEA, $limit INT, $offset INT) PUBLIC VIEW RETURNS TABLE( id INT8, + maa_address TEXT, event_type TEXT, actor_role TEXT, actor_addr TEXT, @@ -329,25 +417,28 @@ PUBLIC VIEW RETURNS TABLE( for $r in SELECT - id, event_type, actor_role, + id, + CASE WHEN maa_address IS NULL THEN NULL ELSE '0x' || encode(maa_address, 'hex') END AS maa_a, + 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 + WHERE rule_id = $rule_id ORDER BY id ASC LIMIT $limit OFFSET $offset { - RETURN NEXT $r.id, $r.event_type, $r.actor_role, $r.actor_a, + RETURN NEXT $r.id, $r.maa_a, $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; } }; +-- maa_is_known: true iff $maa_address is a joined MAA (used later by the exec route to detect MAAs). 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 { + for $r in SELECT 1 AS one FROM maa_instances 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 index 0278ad00..3cb98c51 100644 --- a/tests/streams/maa/create_test.go +++ b/tests/streams/maa/create_test.go @@ -16,10 +16,16 @@ import ( "github.com/trufnetwork/sdk-go/core/util" ) -// Two component keys used across the tests. +// The two component keys used across the tests — these are the golden-vector inputs +// (2MAA-Plan.md §5.4): restricted = 0x11*20, unrestricted = 0x22*20. const ( restrictedHex = "0x1111111111111111111111111111111111111111" unrestrictedHex = "0x2222222222222222222222222222222222222222" + + // Frozen golden vector A (2MAA-Plan.md §5.4): bps 250, allow-list {ob_place_order(0xcc), ob_cancel_order}, + // salt 0xab*32. rule_id is the full 32-byte identifier; maa_address is the derived 20-byte wallet. + goldenRuleIDHex = "a0b517da759b794e2484dc8b9dba8f5211a53dcdf26448f19c7c68699ff7bcf1" + goldenMAAHex = "84da4dbca14d429c719d65a0bb76bd7fa3c5c349" ) func TestMAA(t *testing.T) { @@ -27,7 +33,7 @@ func TestMAA(t *testing.T) { Name: "MAA_RuleStore", SeedStatements: migrations.GetSeedScriptStatements(), FunctionTests: []kwilTesting.TestFunc{ - testMAACreateMatchesGoldenVectorAndGetters(t), + testMAACreateRuleJoinAndGetters(t), testMAAValidation(t), }, }, testutils.GetTestOptionsWithCache()) @@ -72,97 +78,124 @@ func callAs(ctx context.Context, platform *kwilTesting.Platform, caller util.Eth 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 { +// createDefaultRule registers the golden-vector rule signed by `restricted`, returns the rule_id bytes. +func createDefaultRule(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, restricted util.EthereumAddress, feeBps int64, salt []byte) []byte { t.Helper() - var addr []byte - err := callAs(ctx, platform, restricted, "maa_create", []any{ - unrestrictedHex, // $unrestricted_addr - repeat(0xab, 32), // $salt - "eth_truf", // $bridge (token TRUF is derived from this) - "bps", // $fee_mode - feeBps, // $fee_bps - dec(t, "0"), // $fee_flat - []string{"main", "main"}, // $namespaces + 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 []string{"ob_place_order", "ob_cancel_order"}, // $actions - [][]byte{repeat(0xcc, 32), nil}, // $body_hashes + [][]byte{repeat(0xcc, 32), nil}, // $body_hashes }, func(row *common.Row) error { - addr = append([]byte(nil), row.Values[0].([]byte)...) + ruleID = 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 + require.NoError(t, err, "maa_create_rule should succeed") + require.Len(t, ruleID, 32, "rule_id must be 32 bytes (untruncated identifier)") + return ruleID +} + +// joinRule has `funder` join an existing rule_id, returns the derived 20-byte maa_address. +func joinRule(t *testing.T, ctx context.Context, platform *kwilTesting.Platform, funder util.EthereumAddress, ruleID []byte) []byte { + t.Helper() + var maa []byte + err := callAs(ctx, platform, funder, "maa_join", []any{ruleID}, func(row *common.Row) error { + maa = append([]byte(nil), row.Values[0].([]byte)...) + return nil + }) + require.NoError(t, err, "maa_join should succeed") + require.Len(t, maa, 20, "maa_address must be 20 bytes") + return maa } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- -func testMAACreateMatchesGoldenVectorAndGetters(t *testing.T) func(context.Context, *kwilTesting.Platform) error { +func testMAACreateRuleJoinAndGetters(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() - addr := createDefaultMAA(t, ctx, platform, restricted, 250) + // 1) Create the rule (restricted signs). Must reproduce golden-vector A rule_id. + ruleID := createDefaultRule(t, ctx, platform, restricted, 250, repeat(0xab, 32)) + wantRuleID, err := hex.DecodeString(goldenRuleIDHex) + require.NoError(t, err) + require.Equal(t, wantRuleID, ruleID, "maa_create_rule must reproduce golden-vector A rule_id") - // 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") + // 2) Join the rule (unrestricted funder signs). Must reproduce golden-vector A maa_address. + maa := joinRule(t, ctx, platform, unrestricted, ruleID) + wantMAA, err := hex.DecodeString(goldenMAAHex) require.NoError(t, err) - require.Equal(t, wantA, addr, "maa_create must reproduce golden-vector A address") + require.Equal(t, wantMAA, maa, "maa_join must reproduce golden-vector A maa_address") - // maa_is_known(addr) -> true + // maa_is_known(maa) -> true; maa_is_known(random) -> false. var known bool - require.NoError(t, callAs(ctx, platform, restricted, "maa_is_known", []any{addr}, + require.NoError(t, callAs(ctx, platform, restricted, "maa_is_known", []any{maa}, 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, tokenField, feeMode string + // maa_get_rule(rule_id) -> field checks (no bridge/token/enabled/unrestricted on the rule). + var restrField, feeMode string var feeBps int64 - var enabled bool - require.NoError(t, callAs(ctx, platform, restricted, "maa_get_rule", []any{addr}, + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_rule", []any{ruleID}, func(row *common.Row) error { - 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) + restrField = row.Values[1].(string) + feeMode = row.Values[3].(string) + feeBps = row.Values[4].(int64) return nil })) 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) - // maa_get_allowed_actions(addr) -> 2 rows, canonically ordered (cancel before place) + // maa_get_instance(maa) -> MAA maps to both keys + rule_id. + var instMAA, instRule, instRestr, instUnrestr string + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_instance", []any{maa}, + func(row *common.Row) error { + instMAA = row.Values[0].(string) + instRule = row.Values[1].(string) + instRestr = row.Values[2].(string) + instUnrestr = row.Values[3].(string) + return nil + })) + require.Equal(t, "0x"+goldenMAAHex, instMAA) + require.Equal(t, "0x"+goldenRuleIDHex, instRule) + require.Equal(t, restrictedHex, instRestr) + require.Equal(t, unrestrictedHex, instUnrestr) + + // maa_get_allowed_actions(rule_id) -> 2 rows, canonically ordered (cancel before place). var acts []string - require.NoError(t, callAs(ctx, platform, restricted, "maa_get_allowed_actions", []any{addr}, + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_allowed_actions", []any{ruleID}, 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 + // maa_get_events(rule_id) -> CREATE_RULE (restricted) then JOIN (unrestricted). var evtTypes, evtRoles []string - require.NoError(t, callAs(ctx, platform, restricted, "maa_get_events", []any{addr, int64(100), int64(0)}, + require.NoError(t, callAs(ctx, platform, restricted, "maa_get_events", []any{ruleID, int64(100), int64(0)}, func(row *common.Row) error { - evtTypes = append(evtTypes, row.Values[1].(string)) - evtRoles = append(evtRoles, row.Values[2].(string)) + evtTypes = append(evtTypes, row.Values[2].(string)) + evtRoles = append(evtRoles, row.Values[3].(string)) return nil })) - require.Equal(t, []string{"CREATE"}, evtTypes) - require.Equal(t, []string{"restricted"}, evtRoles) + require.Equal(t, []string{"CREATE_RULE", "JOIN"}, evtTypes) + require.Equal(t, []string{"restricted", "unrestricted"}, evtRoles) + + // maa_list_by_restricted accepts a non-0x-prefixed address (input normalization) and finds the rule. + var listed []string + require.NoError(t, callAs(ctx, platform, restricted, "maa_list_by_restricted", + []any{"1111111111111111111111111111111111111111", int64(10), int64(0)}, + func(row *common.Row) error { listed = append(listed, row.Values[0].(string)); return nil })) + require.Equal(t, []string{"0x" + goldenRuleIDHex}, listed, "list_by_restricted must accept a non-0x address") return nil } } @@ -170,43 +203,50 @@ func testMAACreateMatchesGoldenVectorAndGetters(t *testing.T) func(context.Conte 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) + unrestricted := 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) + // Create the canonical rule so collisions below have something to hit. + ruleID := createDefaultRule(t, ctx, platform, restricted, 250, repeat(0xab, 32)) - // 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", "bps", int64(250), dec(t, "0"), + // Duplicate rule identity (same restricted/rules/salt) must be rejected. + require.Error(t, callAs(ctx, platform, restricted, "maa_create_rule", []any{ + repeat(0xab, 32), "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") + }, nil), "duplicate rule 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", "bps", int64(0), dec(t, "0"), + // fee_bps out of range (>10000 = >100%) must be rejected. + require.Error(t, callAs(ctx, platform, restricted, "maa_create_rule", []any{ + repeat(0x02, 32), "bps", int64(10001), dec(t, "0"), []string{}, []string{}, [][]byte{}, - }, nil), "restricted == unrestricted must be rejected") + }, nil), "fee_bps > 10000 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", "bps", int64(10001), dec(t, "0"), + // Negative fee_flat must be rejected by the action-level guard. + require.Error(t, callAs(ctx, platform, restricted, "maa_create_rule", []any{ + repeat(0x05, 32), "flat", int64(0), dec(t, "-1"), []string{}, []string{}, [][]byte{}, - }, nil), "fee_bps > 10000 must be rejected") + }, nil), "negative fee_flat 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"), + // Duplicate (namespace, action) in the allow-list must be rejected. + require.Error(t, callAs(ctx, platform, restricted, "maa_create_rule", []any{ + repeat(0x03, 32), "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") + // maa_join on an unknown rule_id must be rejected. + require.Error(t, callAs(ctx, platform, unrestricted, "maa_join", []any{repeat(0xee, 32)}, nil), + "join of unknown rule_id must be rejected") + + // Self-delegation: the rule creator (restricted) joining its own rule must be rejected. + require.Error(t, callAs(ctx, platform, restricted, "maa_join", []any{ruleID}, nil), + "funder == rule creator must be rejected") + + // First real join succeeds; a second identical join (same funder + rule) must be rejected. + _ = joinRule(t, ctx, platform, unrestricted, ruleID) + require.Error(t, callAs(ctx, platform, unrestricted, "maa_join", []any{ruleID}, nil), + "double join must be rejected") return nil } }