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>
173 lines
5.6 KiB
Go
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}
|
|
}
|