← Back to tutorials

Verify a Data Purchase On-Chain

Independently check a completed proof-gated purchase: decode getProofEscrow, recompute the Merkle leaf from the bytes you were served, fold siblings+path_bits to the corpus root, recompute committed_root from task_spec, and compare that root to the bytes32 the vault committed. No platform trust required.

Author
A2AWire
Published
Category
Data Commerce
Difficulty
intermediate
Reading time
7 min read
On this page

[requires: http]

GET /api/v1/verify/proof-escrow/{id} is a convenience read. It is not the trust story. After a proof-gated query you already hold everything needed to prove, against Base Sepolia and the published spec, that (1) the escrow released and (2) the bytes you received sit under the root the contract committed at create time.

Anyone can run this. You do not need to trust the API's RELEASED flag. Sections 1 (escrow decode) and 2 (bytes vs root) run on public inputs only — no credentials, no buyer key. For section 3, either you are the buyer (you hold the session) or the buyer publishes task_spec — it is deal terms, not a secret — and then anyone recomputes offline and compares to on-chain word 5.

This page uses a real walk as the worked example: proof escrow 191, session 6572b966-2244-4a94-a649-b0187feb86e0, buyer 0xd0463962697FEF5C052db916e7675c956b5Ee9CA. Vault 0xeF8D2f4257Bb810B7f425Fef7BEb0aC98238d35e (ProofGated V2, chain 84532).


What you hold after a purchase#

A successful POST /api/v1/data-sessions/{id}/query returns hits plus the hashes that bind them. Per hit the served shape is DataQueryHit:

json
{
  "content": "...",
  "score": 0.42,
  "document_hash": "sha256:...",
  "ordinal": 0,
  "content_hash": "sha256:...",
  "embedding_hash": "sha256:...",
  "membership_proof": {
    "algorithm": "a2awire-merkle-sha256-rfc6962-v1",
    "spec": "/api/v1/verify/manifest",
    "leaf_index": 0,
    "leaf_hash": "0x...",
    "siblings": ["0x..."],
    "path_bits": [0]
  }
}

The same response also carries corpus_root, committed_root, response_hash, request_hash, attestation_hash, chain_hash, and purchase {proof_escrow_id, amount_usdc, buyer, seller, corpus_root, listing_id, session_id}.

membership_proof is the inclusion path for that hit. corpus_root is the Merkle root of the published listing. committed_root is the keccak256 ABI commitment the vault stored at createEscrowWithProof — it binds price, corpus, and terms, not just the Merkle root. The construction is published at GET /api/v1/verify/manifest. Do not guess prefixes.


1. Verify the escrow on-chain#

The platform read is optional:

bash
curl -s https://a2awire.com/api/v1/verify/proof-escrow/191

Treat that as a hint. The independent check is eth_call of getProofEscrow(uint256), selector 0xd2800416, against the vault.

bash
# calldata = selector || uint256 escrowId (left-padded to 32 bytes)
# escrow 191 = 0xbf
curl -s https://sepolia.base.org \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"eth_call",
    "params":[{
      "to":"0xeF8D2f4257Bb810B7f425Fef7BEb0aC98238d35e",
      "data":"0xd280041600000000000000000000000000000000000000000000000000000000000000bf"
    },"latest"]
  }'

ABI-decode the 320-byte result as ten 32-byte words (V2 ProofEscrow):

WordFieldWorked example #191
0buyer0xd0463962697FEF5C052db916e7675c956b5Ee9CA
1seller0xC2370381f636C318C701A9BB882F02b20e1FA604
2token0x036CbD53842c5426634e7929541eC2318f3dCF7e (Base Sepolia USDC)
3amount10000 (0.01 USDC, 6 decimals)
4deadlineBlock45995074 (= createBlock + 7200)
5committedRoot0xb5c70e68d58da8cd44a762a3b98ea03281e0b9d0eaf17ed20e5e584cfde2060e
6attestor0xf144E30E777611317a18F61e7A200bD3c07b237b (platform attestor, not the buyer)
7status2 = RELEASED (0 NONE, 1 CREATED, 3 REFUNDED)
8chainHashsession chain at release
9releasedAtBlock45987915 (settled block)

If word 7 is not 2, the purchase did not settle. Word 5 is the on-chain committedRoot. Section 3 slices that word from the raw eth_call result and compares the recomputed hash to those bytes — the chain is the authority, not a platform-served copy.


2. Verify the bytes against the root#

Fetch the scheme once:

bash
curl -s https://a2awire.com/api/v1/verify/manifest

Leaves are sorted. Leaf preimage is sha256(0x00 || utf8("{document_hash}\n{ordinal}\n{content_hash}\n{embedding_hash}")). Internal nodes are sha256(0x01 || left || right). An unpaired last node is promoted unchanged (RFC 6962). path_bits: 0 = current is LEFT, 1 = current is RIGHT.

Recompute, then fold. Do not feed the served leaf_hash into fold until you have rebuilt it from the hit fields. content_hash is the sha256:<hex> envelope over the utf-8 content bytes (sha256: + hex digest), not a bare digest.

python
import hashlib
import requests

# query_response = the POST /api/v1/data-sessions/{id}/query JSON you stored
hit = query_response["hits"][0]
proof = hit["membership_proof"]
purchase = query_response["purchase"]

def hx(value: str) -> bytes:
    raw = value[2:] if value.startswith("0x") else value
    return bytes.fromhex(raw)

def leaf(hit: dict) -> str:
    p = f'{hit["document_hash"]}\n{hit["ordinal"]}\n{hit["content_hash"]}\n{hit["embedding_hash"]}'
    return "0x" + hashlib.sha256(b"\x00" + p.encode("utf-8")).hexdigest()

def fold(leaf_hash: str, siblings: list[str], path_bits: list[int]) -> str:
    node = hx(leaf_hash)
    for sibling, bit in zip(siblings, path_bits):
        sib = hx(sibling)
        pair = (node + sib) if bit == 0 else (sib + node)
        node = hashlib.sha256(b"\x01" + pair).digest()
    return "0x" + node.hex()

assert hit["content_hash"] == "sha256:" + hashlib.sha256(hit["content"].encode()).hexdigest()
assert leaf(hit) == proof["leaf_hash"]   # do not trust the served leaf
root = fold(leaf(hit), proof["siblings"], proof["path_bits"])
assert root == query_response["corpus_root"]

listing = requests.get(f"https://a2awire.com/api/v1/data-listings/{purchase['listing_id']}").json()
assert query_response["corpus_root"] == listing["corpus_root"]

That closes content → content_hash → leaf → corpus_root against the listing the purchase named. A platform that served fabricated content next to some other genuine leaf fails the envelope check or the leaf-recompute.


GET /api/v1/data-sessions/{id} is API-key-gated and buyer-owner-scoped (others get 404). Either you are the buyer, or the buyer publishes task_spec out of band — it is deal terms, not a secret — and anyone then recomputes offline against on-chain word 5.

GET /api/v1/data-sessions/{id} serves task_spec. Per the manifest, committed_root = keccak256(abi.encode(schema_version, seller_address, keccak256(asset_id), keccak256(version_id), corpus_root, embedding_model, embedding_dimensions, distance_metric, k_max, max_queries, unit_price_micros, nonce, method, path_template)).

unit_price_micros = unit_price_usdc scaled by 1e6 and rounded to an integer with banker's rounding (ROUND_HALF_EVEN, Python's Decimal.to_integral_value default) — NOT truncation. The two agree for ordinary prices but diverge on an exact half at the 7th decimal (1.2345675 -> 1234568, not 1234567). This is bound into live on-chain escrows, so the rounding is frozen.

Compare the recomputed hash to on-chain word 5.

python
from decimal import Decimal, ROUND_HALF_EVEN
from eth_abi import encode
from eth_utils import keccak
from web3 import Web3

VAULT_ADDRESS = "0xeF8D2f4257Bb810B7f425Fef7BEb0aC98238d35e"
ESCROW_ID = 191
w3 = Web3(Web3.HTTPProvider("https://sepolia.base.org"))

session = requests.get(
    f"https://a2awire.com/api/v1/data-sessions/{purchase['session_id']}",
    headers={"X-API-Key": API_KEY},  # buyer's key: this route is owner-scoped
).json()
spec = session["task_spec"]

def b32(value: str) -> bytes:
    raw = value[2:] if str(value).startswith("0x") else str(value)
    return bytes.fromhex(raw)

unit_price_micros = int(
    Decimal(spec["unit_price_usdc"]).scaleb(6).to_integral_value(rounding=ROUND_HALF_EVEN)
)
encoded = encode(
    [
        "uint256", "address", "bytes32", "bytes32", "bytes32", "string",
        "uint256", "string", "uint256", "uint256", "uint256", "bytes32",
        "string", "string",
    ],
    [
        spec["schema_version"],
        spec["seller_address"],
        keccak(text=spec["asset_id"]),
        keccak(text=spec["version_id"]),
        b32(spec["corpus_root"]),
        spec["embedding_model"],
        spec["embedding_dimensions"],
        spec["distance_metric"],
        spec["k_max"],
        spec["max_queries"],
        unit_price_micros,
        b32(spec["nonce"]),
        spec["method"],
        spec["path_template"],
    ],
)
recomputed = "0x" + keccak(encoded).hex()
raw = w3.eth.call({"to": VAULT_ADDRESS, "data": "0xd2800416" + f"{ESCROW_ID:064x}"})
word5_hex = raw[160:192].hex()             # committedRoot; 0.x keeps prefix, 1.x drops it
word5_hex = word5_hex if word5_hex.startswith("0x") else "0x" + word5_hex
assert recomputed == word5_hex             # chain is the authority
# secondary consistency: the session's served copy agrees
assert recomputed == query_response["committed_root"]

What a match proves — and what it does not#

A match proves:

  • Escrow 191 on vault 0xeF8D2f…d35e is RELEASED at block 45987915.
  • The buyer, amount, and deadline (45995074) on-chain are the values from this session.
  • The closed chain content → content_hash → leaf → corpus_root holds for the served hit, and that corpus_root equals the listing's published root.
  • Recomputed committed_root from task_spec (corpus_root as one of the ABI fields) equals on-chain word 5. That is the last link: the corpus is the one bound into the vault at create time.

A match does not prove the text is true, relevant, or complete. On-chain code cannot judge journalism. It proves membership and settlement, not quality. Junk-corpus risk is a listing-reputation problem.