diff --git a/README.md b/README.md index b8bc8e9..814c9fe 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,28 @@ client, _ := attesto.NewClient(apiKey, attesto.WithHeadStore(attesto.NewFileHead client, _ = attesto.NewClient(apiKey, attesto.WithHeadStore(nil)) ``` +## Verify anchors on-chain + +`VerifyAnchorOnchain` checks an anchor epoch against the chain itself — one raw +JSON-RPC `eth_call` to the anchoring contract's `getCommitment(batchId)` +(comparing the on-chain merkle root) plus a transaction-receipt check (status, +block). No web3 dependency; the RPC endpoint is yours, so this never asks +Attesto to confirm Attesto. + +```go +anchor, _ := client.GetAnchorEpoch(ctx, "aep_...") +report := attesto.VerifyAnchorOnchain(ctx, anchor, "https://polygon-rpc.example", 15*time.Second) +if !report.OK { + log.Fatalf("anchor failed on-chain verification: %v", report.Problems) +} +``` + +CLI equivalent (fetch + on-chain check in one step): + +```bash +attesto anchors verify aep_... --rpc-url https://polygon-rpc.example +``` + ## Receiving Attesto webhooks ```go diff --git a/anchors.go b/anchors.go new file mode 100644 index 0000000..3ae6070 --- /dev/null +++ b/anchors.go @@ -0,0 +1,172 @@ +package attesto + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +// GetCommitmentSelector is the first 4 bytes of keccak256("getCommitment(string)"), +// fixed by the contract ABI (APSProvenance.abi.json); pinned so no keccak +// implementation is needed. +const GetCommitmentSelector = "a7b09e2a" + +// EncodeGetCommitmentCall ABI-encodes getCommitment(string) calldata: selector, +// then the single dynamic string argument (offset word, length word, padded bytes). +func EncodeGetCommitmentCall(batchID string) string { + raw := []byte(batchID) + pad := (32 - len(raw)%32) % 32 + body := make([]byte, 0, 64+len(raw)+pad) + offset := make([]byte, 32) + offset[31] = 0x20 + length := make([]byte, 32) + for i, v := 31, len(raw); v > 0; i, v = i-1, v>>8 { + length[i] = byte(v & 0xff) + } + body = append(body, offset...) + body = append(body, length...) + body = append(body, raw...) + body = append(body, make([]byte, pad)...) + return "0x" + GetCommitmentSelector + hex.EncodeToString(body) +} + +func stripHex(value string) string { + return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") +} + +func rpcCall(ctx context.Context, httpClient *http.Client, rpcURL, method string, params []any) (json.RawMessage, string) { + payload, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": method, "params": params, + }) + if err != nil { + return nil, "rpc " + method + " failed" + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(payload)) + if err != nil { + return nil, "rpc " + method + " failed" + } + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient.Do(req) + if err != nil { + return nil, "rpc " + method + " failed" + } + defer resp.Body.Close() + var envelope struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&envelope) != nil { + return nil, "rpc " + method + " failed" + } + if len(envelope.Error) > 0 && string(envelope.Error) != "null" { + return nil, "rpc " + method + " failed" + } + if len(envelope.Result) == 0 { + return nil, "rpc " + method + " failed" + } + return envelope.Result, "" +} + +func anchorField(epoch map[string]any, keys ...string) (string, bool) { + for _, key := range keys { + if value, ok := epoch[key].(string); ok && value != "" { + return value, true + } + } + return "", false +} + +// VerifyAnchorOnchain verifies an anchor epoch against the chain itself via raw +// JSON-RPC: one eth_call to getCommitment(batchId) comparing the on-chain merkle +// root with the anchor's merkle_root, plus one eth_getTransactionReceipt +// confirming the anchoring transaction succeeded in the expected block. The +// anchor object is accepted in snake_case or camelCase; the contract address is +// read from the epoch's hashed payload. rpcURL is customer-chosen — this +// function never talks to Attesto. +func VerifyAnchorOnchain(ctx context.Context, anchorEpoch map[string]any, rpcURL string, timeout time.Duration) VerifyReport { + problems := make([]string, 0) + + payload, _ := anchorEpoch["payload"].(map[string]any) + merkleRoot, okRoot := anchorField(anchorEpoch, "merkle_root", "merkleRoot") + txHash, okTx := anchorField(anchorEpoch, "tx_hash", "txHash") + batchID, okBatch := anchorField(anchorEpoch, "anchor_batch_id", "anchorBatchId") + contractAddress, okAddr := anchorField(anchorEpoch, "contract_address", "contractAddress") + if !okAddr && payload != nil { + contractAddress, okAddr = anchorField(payload, "contract_address") + } + blockNumber := int64(-1) + switch v := anchorEpoch["block_number"].(type) { + case float64: + blockNumber = int64(v) + case int64: + blockNumber = v + default: + switch v := anchorEpoch["blockNumber"].(type) { + case float64: + blockNumber = int64(v) + case int64: + blockNumber = v + } + } + if !okRoot || !okTx || !okBatch || !okAddr || blockNumber < 0 { + return VerifyReport{ + Kind: "anchor-onchain", OK: false, + Problems: []string{"anchor epoch is missing required fields"}, + } + } + + if timeout <= 0 { + timeout = 15 * time.Second + } + httpClient := &http.Client{Timeout: timeout} + + callResult, problem := rpcCall(ctx, httpClient, rpcURL, "eth_call", []any{ + map[string]any{"to": contractAddress, "data": EncodeGetCommitmentCall(batchID)}, + "latest", + }) + if problem != "" { + problems = append(problems, problem) + } else { + var returned string + if json.Unmarshal(callResult, &returned) != nil { + problems = append(problems, "rpc eth_call failed") + } else { + // Return tuple is (bytes32 merkleRoot, uint32, string, uint64); + // the root is the first 32-byte word. + onchain := stripHex(returned) + if len(onchain) < 64 || onchain[:64] != stripHex(merkleRoot) { + problems = append(problems, "anchor merkle root mismatch") + } + } + } + + receiptResult, problem := rpcCall(ctx, httpClient, rpcURL, "eth_getTransactionReceipt", []any{txHash}) + if problem != "" { + problems = append(problems, problem) + } else if string(receiptResult) == "null" { + problems = append(problems, "anchor transaction not found") + } else { + var receipt struct { + Status string `json:"status"` + BlockNumber string `json:"blockNumber"` + } + if json.Unmarshal(receiptResult, &receipt) != nil { + problems = append(problems, "anchor transaction not found") + } else { + if receipt.Status != "0x1" { + problems = append(problems, "anchor transaction failed") + } + mined, err := strconv.ParseInt(stripHex(receipt.BlockNumber), 16, 64) + if err != nil || mined != blockNumber { + problems = append(problems, "anchor transaction block mismatch") + } + } + } + + return VerifyReport{Kind: "anchor-onchain", OK: len(problems) == 0, Problems: problems} +} diff --git a/anchors_test.go b/anchors_test.go new file mode 100644 index 0000000..c16d93b --- /dev/null +++ b/anchors_test.go @@ -0,0 +1,131 @@ +package attesto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const ( + anchorRoot = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + anchorTx = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + anchorBlock = int64(12345) +) + +func anchorEpochFixture() map[string]any { + return map[string]any{ + "anchorEpochId": "aep_demo", + "merkleRoot": "0x" + anchorRoot, + "txHash": anchorTx, + "blockNumber": float64(anchorBlock), + "anchorBatchId": "batch_demo", + "chainId": float64(137), + "payload": map[string]any{"contract_address": "0x" + strings.Repeat("d", 40)}, + } +} + +func mockRPC(t *testing.T, callRoot string, receipt map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Method string `json:"method"` + Params []any `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + switch body.Method { + case "eth_call": + // (bytes32, uint32, string, uint64) — only the first word matters. + result := "0x" + callRoot + strings.Repeat("0", 192) + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": result}) + case "eth_getTransactionReceipt": + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": receipt}) + default: + w.WriteHeader(http.StatusBadRequest) + } + })) +} + +func TestEncodeGetCommitmentCallMatchesPinnedExample(t *testing.T) { + want := "0xa7b09e2a" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "000000000000000000000000000000000000000000000000000000000000000a" + + "62617463685f64656d6f00000000000000000000000000000000000000000000" + if got := EncodeGetCommitmentCall("batch_demo"); got != want { + t.Errorf("calldata = %s, want %s", got, want) + } +} + +func TestABIStillDeclaresGetCommitmentString(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "APSProvenance.abi.json")) + if err != nil { + t.Fatal(err) + } + var abi []map[string]any + if err := json.Unmarshal(raw, &abi); err != nil { + t.Fatal(err) + } + for _, entry := range abi { + if entry["name"] == "getCommitment" { + inputs := entry["inputs"].([]any) + if len(inputs) != 1 || inputs[0].(map[string]any)["type"] != "string" { + t.Fatalf("getCommitment inputs changed: %v", inputs) + } + return + } + } + t.Fatal("getCommitment not found in ABI") +} + +func TestAnchorVerifiesWhenChainMatches(t *testing.T) { + srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x1", "blockNumber": "0x3039"}) + defer srv.Close() + report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second) + if !report.OK { + t.Errorf("expected ok, problems: %v", report.Problems) + } +} + +func TestAnchorRootMismatch(t *testing.T) { + srv := mockRPC(t, strings.Repeat("e", 64), map[string]any{"status": "0x1", "blockNumber": "0x3039"}) + defer srv.Close() + report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second) + if report.OK || !containsProblem(report.Problems, "anchor merkle root mismatch") { + t.Errorf("problems: %v", report.Problems) + } +} + +func TestAnchorFailedTransaction(t *testing.T) { + srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x0", "blockNumber": "0x3039"}) + defer srv.Close() + report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second) + if !containsProblem(report.Problems, "anchor transaction failed") { + t.Errorf("problems: %v", report.Problems) + } +} + +func TestAnchorWrongBlock(t *testing.T) { + srv := mockRPC(t, anchorRoot, map[string]any{"status": "0x1", "blockNumber": "0x3040"}) + defer srv.Close() + report := VerifyAnchorOnchain(context.Background(), anchorEpochFixture(), srv.URL, time.Second) + if !containsProblem(report.Problems, "anchor transaction block mismatch") { + t.Errorf("problems: %v", report.Problems) + } +} + +func containsProblem(problems []string, want string) bool { + for _, p := range problems { + if p == want { + return true + } + } + return false +} diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index 776e3b5..8aca9b8 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "time" attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go/connectorkit" @@ -124,7 +125,7 @@ func (a *app) dispatch(ctx context.Context, args []string) error { case "witnesses": return a.witnesses(ctx, args[1:]) case "anchors": - return a.verifyableObject(ctx, "anchors", args[1:], attesto.VerifyAnchor, "/v2/anchors/") + return a.anchors(ctx, args[1:]) case "bundles": return a.bundles(ctx, args[1:]) case "verify": @@ -410,6 +411,54 @@ func (a *app) verifyableObject(ctx context.Context, group string, args []string, } } +func (a *app) anchors(ctx context.Context, args []string) error { + // `anchors verify --rpc-url ` chains an API fetch + // with the on-chain check (eth_call getCommitment + tx receipt) against a + // customer-chosen RPC endpoint. Without --rpc-url, all subcommands keep the + // existing get / remote-verify behavior. + if len(args) > 0 && args[0] == "verify" { + rest := args[1:] + positional := "" + if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") { + positional, rest = rest[0], rest[1:] + } + fs := flag.NewFlagSet("anchors verify", flag.ContinueOnError) + fs.SetOutput(a.err) + id := fs.String("id", positional, "anchor epoch id") + rpcURL := fs.String("rpc-url", "", "JSON-RPC endpoint for the anchor's chain") + timeoutS := fs.Int("timeout-s", 15, "RPC timeout in seconds") + file := fs.String("file", "", "proof object JSON file (remote verify mode)") + publicKeyHex := fs.String("public-key-hex", "", "Ed25519 public key hex (remote verify mode)") + if err := fs.Parse(rest); err != nil { + return err + } + if *rpcURL == "" { + fallback := []string{} + if *file != "" { + fallback = append(fallback, "--file", *file) + } + if *publicKeyHex != "" { + fallback = append(fallback, "--public-key-hex", *publicKeyHex) + } + return a.remoteVerify(ctx, fallback, attesto.VerifyAnchor) + } + if *id == "" { + return errors.New("anchor epoch id is required (positional or --id)") + } + client, err := a.systemClient() + if err != nil { + return err + } + anchor, err := client.GetAnchorEpoch(ctx, *id) + if err != nil { + return err + } + report := attesto.VerifyAnchorOnchain(ctx, anchor, *rpcURL, time.Duration(*timeoutS)*time.Second) + return a.write(report) + } + return a.verifyableObject(ctx, "anchors", args, attesto.VerifyAnchor, "/v2/anchors/") +} + func (a *app) checkpoints(ctx context.Context, args []string) error { if len(args) == 0 { return errors.New("checkpoints subcommand required") diff --git a/testdata/APSProvenance.abi.json b/testdata/APSProvenance.abi.json new file mode 100644 index 0000000..59e149f --- /dev/null +++ b/testdata/APSProvenance.abi.json @@ -0,0 +1,172 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "batchId", + "type": "string" + } + ], + "name": "AlreadyCommitted", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyBatchId", + "type": "error" + }, + { + "inputs": [], + "name": "NotOwner", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "string", + "name": "batchId", + "type": "string" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "runCount", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "string", + "name": "model", + "type": "string" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "blockTimestamp", + "type": "uint64" + } + ], + "name": "RootCommitted", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "batchIds", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "batchId", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "runCount", + "type": "uint32" + }, + { + "internalType": "string", + "name": "model", + "type": "string" + } + ], + "name": "commitRoot", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getBatchCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "batchId", + "type": "string" + } + ], + "name": "getCommitment", + "outputs": [ + { + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "runCount", + "type": "uint32" + }, + { + "internalType": "string", + "name": "model", + "type": "string" + }, + { + "internalType": "uint64", + "name": "blockTimestamp", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file