-
Notifications
You must be signed in to change notification settings - Fork 3
chore: derive an agent wallet's address from its rules #1387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+534
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.