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_Acovers the AOTrust signature frameu32le(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_Aoragent_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 carry0x03;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.
| Form | Location |
|---|---|
| dynamic endpoint | https://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:web | did:web:api.aotrust.link → https://api.aotrust.link/.well-known/did.json |
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.
| Item | Value |
|---|---|
| reference (shield id) | 33a3d62a |
| work_hash | 2f77a4bc0d904c921270e3071873432487d584404610b43103807edebc9b1dca |
| agent_pubkey | f47e48f08b9b54062f33b7291e54b35d94421bd0b020caa9f8662c1b287c4e63 |
| agent_sig (base64) | rut1555qnULh/yq/+coY3PG/tKbzrbrWjIJNV9ABiVDQmTgxfjGAHaryLs1A3D95jOwFgkEPNrlLXEnwkWXBCw== |
| expected binding hash | 7719c27149ad91b4b970e9bfd05b2f746539bb39e7b07126122aaf7528d77f21 |
| payload_hash in PDR | 7719c27149ad91b4b970e9bfd05b2f746539bb39e7b07126122aaf7528d77f21 |
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_hashthere. - Anchoring: both versions are leaves in the cumulative Merkle log, anchored hourly to NEAR;
merkle_rootat 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