sdk(P1.5): on-chain anchor verification with zero heavy dependencies

verify_anchor_onchain / verifyAnchorOnchain / VerifyAnchorOnchain check an
anchor epoch against the chain itself in all three SDKs: one raw JSON-RPC
eth_call to the anchoring contract's getCommitment(batchId) comparing the
on-chain merkle root with the anchor's merkle_root, plus one
eth_getTransactionReceipt confirming status == 0x1 in the expected block.
The customer chooses the RPC endpoint — nothing asks Attesto to confirm
Attesto, and no web3/ethers dependency is added anywhere.

The getCommitment(string) selector (keccak256 first 4 bytes = a7b09e2a) is
pinned as a constant with the dynamic-string ABI encoding done manually;
a worked calldata example (computed once against web3 keccak) is asserted in
all three test suites, and APSProvenance.abi.json is copied into each SDK's
testdata with a test that flags the pinned selector for review if the ABI's
getCommitment signature ever changes. The contract address is read from the
anchor epoch's hashed payload (payload.contract_address).

Mocked-RPC tests cover match / root-mismatch / failed-tx / wrong-block /
missing-fields in each language with identical problem strings; a live test
against the production contract runs only when ATTESTO_LIVE_RPC_URL is set.
Go CLI gains `attesto anchors verify <id> --rpc-url <url>` (API fetch +
on-chain check in one step; existing get/remote-verify behavior unchanged).
READMEs updated per SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-11 18:31:18 +02:00
parent 7d3e8c5b4f
commit 8781fa57d8
5 changed files with 547 additions and 1 deletions

View File

@@ -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

172
anchors.go Normal file
View File

@@ -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}
}

131
anchors_test.go Normal file
View File

@@ -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
}

View File

@@ -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 <anchor_epoch_id> --rpc-url <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")

172
testdata/APSProvenance.abi.json vendored Normal file
View File

@@ -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"
}
]