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(), }, } }