Files
attesto-go/anchors.go
Codex 8781fa57d8 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>
2026-06-11 18:31:18 +02:00

173 lines
5.6 KiB
Go

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