Bilateral PDR (v0x04)

The agent's own Ed25519 signature is bound into the notary-signed proof. Two parties attest the same artifact: the notary proves when it existed, the agent proves who submitted exactly that work hash. Same price as an ordinary notarization - $0.01.

In production since 2026-08-15 · version byte 0x04 · ordinary proof stays 0x03

1. How to request it

Send the usual notarize request with two extra fields: agent_sig (base64, 64-byte Ed25519 signature) and agent_pubkey (hex, 32-byte public key). The signature covers the AOTrust signature frame of the 32-byte work hash - not the raw hash bytes:

sig_A = Ed25519_sign( agent_key, NEP413(work_hash_bytes) )
NEP413(m) = u32le(2147484061) || u32le(len(m)) || m           # tag = 2^31 + 413

POST https://api.aotrust.link/notarize        (x402, $0.01 USDC on Base)
{
  "work_hash":    "<64 hex - sha256 of your artifact>",
  "agent_sig":    "<base64 - sig_A above>",
  "agent_pubkey": "<64 hex - your Ed25519 public key>"
}

Agent side, copy-paste (Python + PyNaCl):

import base64, hashlib, struct
from nacl.signing import SigningKey
TAG = 2147484061
sk = SigningKey(bytes.fromhex("<32-byte secret key hex>"))
work_hash = bytes.fromhex(hashlib.sha256(b"my artifact").hexdigest())
sig_a = sk.sign(struct.pack("<II", TAG, len(work_hash)) + work_hash).signature
print("agent_sig =", base64.b64encode(sig_a).decode())
print("agent_pubkey =", sk.verify_key.encode().hex())

A signature over the bare 32 raw bytes is rejected - the frame is mandatory (same rule the notary uses for its own signature). Without the two fields the endpoint returns an ordinary v0x03 proof where payload_hash = work_hash.

2. Binding hash

payload_hash = sha256( work_hash_bytes(32) || sig_A_bytes(64) || agent_pubkey_bytes(32) )

This 32-byte value is written into payload_hash at offset 79 of the v0x04 payload, and the whole payload is signed by the notary. Because sig_A and agent_pubkey are inside the hash, neither can be swapped after issuance without breaking the notary signature.

  • sig_A covers the AOTrust signature frame u32le(2147484061) || u32le(32) || work_hash (Ed25519, no pre-hash). A raw-bytes signature fails verification.
  • Order is fixed left-to-right: work hash, then signature, then public key - 128 bytes total.
  • v0x03 (ordinary): payload_hash = work_hash; v0x04 (bilateral): the binding hash above.
  • The PDR does not carry sig_A or agent_pubkey; it commits to them. A verifier needs the triple from the agent to recompute the binding hash.

3. Wire format - 239 bytes, base64

The 239-byte layout is defined once, in the canonical table on the reference page. A bilateral receipt uses the same layout and differs in exactly two slots:

  • version (offset 0, 1 byte) = 0x04 - ordinary receipts carry 0x03;
  • payload_hash (offset 79, 32 bytes) = the binding hash above, not the work hash.

The notary signs 175 payload bytes in a AOTrust signature frame, not the file and not a JWT:

buffer  = struct.pack("<II", 2147484061, 175) || payload      # tag = 2^31 + 413
signature = Ed25519_sign(notary_seed, buffer)                 # raw bytes, no SHA-256 pre-hash

4. Verification keys

One notary signing key, published in two standard forms. Use whichever your stack already speaks.

FormLocation
dynamic endpointhttps://api.aotrust.link/v1/notary/pubkey → {"pubkey_hex": "…", "issuer_id": "notary-node.near"}
JWKS (RFC 7517)https://api.aotrust.link/.well-known/jwks.json
JWKS (static mirror)https://verify.aotrust.link/.well-known/jwks.json
did:webdid:web:api.aotrust.link → https://api.aotrust.link/.well-known/did.json
kid (RFC 7638 thumbprint)
1IO8CqvLqS030_sA040Gn0okbAOX4OTK4QHC3mfl6fM
public key (hex, Ed25519)
490f51f23b993eacaff54fc977d9a7689ab7d4ae91504dc6cbdeadb2dbf1f462

Rotation policy: a new key is published alongside the old one under a new kid; old keys are kept for verification of historical PDRs. Always select the key by kid when more than one is listed. This is a dedicated PDR signing key - it is not a payment key and not the anchor key: NEAR anchor transactions for notary-node.near are signed by a separate account key (ed25519:EUb4BxVPH1jiTsMfyN4Z8KxGNHzRNdah7Ug75fc1DTvQ, verifiable on-chain via view_access_key_list).

5. Verify offline (no trust in our API)

Python 3, one dependency: pynacl. The snippet recomputes nothing vendor-specific - it verifies the notary signature and, for v0x04, the binding hash.

import base64, hashlib, struct, sys
from nacl.signing import VerifyKey

NOTARY_PUB = bytes.fromhex("490f51f23b993eacaff54fc977d9a7689ab7d4ae91504dc6cbdeadb2dbf1f462")
TAG = 2147484061

pdr = base64.b64decode(sys.argv[1]); assert len(pdr) == 239   # 239 bytes
payload, sig = pdr[:175], pdr[175:]
version, scheme, pat, ts, issuer, subject, payload_hash, merkle, pay = \
    struct.unpack("<BBBQ36s32s32s32s32s", payload)

# 1. notary signature over the 175-byte payload
VerifyKey(NOTARY_PUB).verify(struct.pack("<II", TAG, 175) + payload, sig)

# 2. bilateral: agent signature + binding hash
if version == 0x04:
    wh    = bytes.fromhex(sys.argv[2])          # 32 bytes
    sig_a = base64.b64decode(sys.argv[3])       # 64 bytes
    pub   = bytes.fromhex(sys.argv[4])          # 32 bytes
    VerifyKey(pub).verify(struct.pack("<II", TAG, len(wh)) + wh, sig_a)
    assert hashlib.sha256(wh + sig_a + pub).digest() == payload_hash

print("OK version=%d anchor=%d issuer=%s ts=%d" % (version, pat, issuer.rstrip(b"\x00").decode(), ts))

Server-side alternative (convenience, not a trust anchor):

curl -s "https://api.aotrust.link/v1/pdr/verify/<base64url PDR>"
curl -s "https://api.aotrust.link/v1/shield/lookup/<8 hex id>"   # fetch a PDR by reference

6. Live test vector (mainnet, seq 71)

A real bilateral PDR, paid $0.01 on Base, issued 2026-09-02T18:07:53Z, publicly retrievable. Every value below was produced by the production service; the expected binding hash is what the snippet above must compute.

ItemValue
reference (shield id)33a3d62a
work_hash2f77a4bc0d904c921270e3071873432487d584404610b43103807edebc9b1dca
agent_pubkeyf47e48f08b9b54062f33b7291e54b35d94421bd0b020caa9f8662c1b287c4e63
agent_sig (base64)rut1555qnULh/yq/+coY3PG/tKbzrbrWjIJNV9ABiVDQmTgxfjGAHaryLs1A3D95jOwFgkEPNrlLXEnwkWXBCw==
expected binding hash7719c27149ad91b4b970e9bfd05b2f746539bb39e7b07126122aaf7528d77f21
payload_hash in PDR7719c27149ad91b4b970e9bfd05b2f746539bb39e7b07126122aaf7528d77f21

PDR (base64, 239 bytes):

BAEF+WWYagAAAABub3Rhcnktbm9kZS5uZWFyAAAAAAAAAAAAAAAAAAAAAAAAAAD0fkjwi5tUBi8ztykeVLNdlEIb0LAgyqn4ZiwbKHxOY3cZwnFJrZG0uXDpv9BbL3RlObs557BxJhIqr3Uo138hyvx6ifH7DfHxJqfo5rUETA+g513NHXBW9ceOXN7UP7gzo9YqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACpwfeJ/SuHNAh+N8gtW4S0dTZGzP3KvgIDFOKzp4VClNflbQTsrcUKEKwpQhIq6s0rM4NTOAz6WKxrIumXl/AE=

Run: python3 verify.py "<PDR>" 2f77…1dca "rut1…BCw==" f47e…4e63 → OK version=4 anchor=5 issuer=notary-node.near ts=1788372473.

7. Notes

  • Price parity: bilateral costs the same as ordinary ($0.01). The extra signature buys the who, not a faster queue.
  • Backward compatible: v0x03 PDRs already issued remain valid; payload_hash = work_hash there.
  • Anchoring: both versions are leaves in the cumulative Merkle log, anchored hourly to NEAR; merkle_root at offset 111 carries the segment root at issuance, and the live inclusion path is served by /v1/anchor/proof/{seq}.
  • What it does not prove: that the agent's key belongs to a legal entity - identity binding is the agent's own problem. We attest arithmetic, not paperwork.
  • Questions: legal [at] aotrust.link. Machine summary: llms.txt.

AOTrust · PDR v2.3 / v0x04 specification · last updated 2026-10-22

© 2026 AOTrust