Custom attestor
Register an AttestorProfile, respond to validation requests, revoke attestations. Full lifecycle with the live four-signature devnet trace.
A custom attestor registers a profile, watches off-chain for validation requests for capabilities it can attest to, signs respond_to_validation to write the attestation PDA, and can later revoke any attestation it issued. PolicyVault then reads the resulting PDA at fixed byte offsets.
Source: programs/validation-registry/. Devnet smoke: examples/attestor-demo/.
Lifecycle
1. register_attestor → create AttestorProfile PDA
2. <off-chain> → discover ValidationRequest events for capabilities you attest to
3. respond_to_validation → create ValidationAttestation PDA (PolicyVault now reads it)
4. revoke_validation → set revoked = true (PolicyVault now denies)Step 1 — register the profile
import { Keypair, sendAndConfirmTransaction, Transaction } from "@solana/web3.js";
import {
buildRegisterAttestorIx,
loadValidationRegistry,
makeProvider,
DEFAULT_DEVNET_PROGRAM_IDS,
} from "@agenttrust-sdk/trustgate";
const attestor = Keypair.fromSecretKey(/* the attestor's signing key */);
const provider = makeProvider({
rpcUrl: "https://api.devnet.solana.com",
wallet: attestor,
});
const validationRegistry = await loadValidationRegistry(
provider,
DEFAULT_DEVNET_PROGRAM_IDS.validationRegistry,
);
const ix = await buildRegisterAttestorIx({
program: validationRegistry,
attestor: attestor.publicKey,
displayNameUri: "https://my-org.example/attestor.json", // ≤ 100 bytes
});
const tx = new Transaction().add(ix);
const sig = await sendAndConfirmTransaction(provider.connection, tx, [attestor]);Constraints: display_name_uri.len() <= 100 (UriTooLong on overflow). Self-registered (the signer is the attestor). PDA: ["attestor", attestor_pubkey]. Account size includes counters for total attestations + total revoked, all initialised to zero.
Step 2 — discover validation requests
request_validation emits the RequestCreated event. Any off-chain process can subscribe:
import { PublicKey } from "@solana/web3.js";
provider.connection.onLogs(
validationRegistry.programId,
(logs) => {
if (logs.err) return;
// Decode logs.logs entries — RequestCreated event has subject_asset,
// capability_hash, requester, claim_uri_hash, deadline.
},
"confirmed",
);For production attestors, Helius webhooks on the program's events stream is the standard pattern. The PDA itself is just an audit-trail record; attestors don't read it.
Step 3 — respond with an attestation
After the attestor verifies the off-chain claim (KYC document, audit report, model-card statement, etc.) the attestor signs respond_to_validation to write the on-chain attestation:
import {
buildRespondToValidationIx,
computeCapabilityHash,
} from "@agenttrust-sdk/trustgate";
const subjectAsset = new PublicKey("…"); // payee asset
const capabilityHash = computeCapabilityHash("kyc.tier-1.v1"); // 32 bytes
const claimPayloadHash = sha256(claim_payload_bytes); // 32 bytes
const claimUriHash = sha256(Buffer.from(claim_uri_string)); // 32 bytes
const expiresAt = currentSlot + (30n * 24n * 60n * 60n * 2n); // ~30 days @ 2 slots/sec
const ix = await buildRespondToValidationIx({
program: validationRegistry,
payer: attestor.publicKey, // attestor pays rent
attestor: attestor.publicKey,
subjectAsset,
capabilityHash,
claimPayloadHash,
claimUriHash,
expiresAt: Number(expiresAt),
});
const sig = await sendAndConfirmTransaction(provider.connection, new Transaction().add(ix), [attestor]);Constraints: expires_at == 0 (never expires) OR expires_at > clock.slot (ExpiryInPast otherwise). The capability_namespace PDA must already exist (AccountNotInitialized if you reference an unregistered capability). The attestor_profile PDA must exist (the attestor must have called register_attestor first).
PDA: ["attestation", subject_asset, capability_hash, attestor]. Account size: 290 bytes.
The v1 trust model is "attestor signs the tx" — the Solana tx signature itself authenticates the attestor. The 64-byte attestor_signature field on ValidationAttestation is reserved for v1.1+ Ed25519 sysvar verification, which adds non-repudiation against future key compromise.
Step 4 — revoke when needed
import { buildRevokeValidationIx } from "@agenttrust-sdk/trustgate";
const revocationReasonHash = sha256(Buffer.from("kyc-document-expired"));
const ix = await buildRevokeValidationIx({
program: validationRegistry,
attestor: attestor.publicKey,
subjectAsset,
capabilityHash,
revocationReasonHash,
});
await sendAndConfirmTransaction(provider.connection, new Transaction().add(ix), [attestor]);revoke_validation is audit-trail-preserving: it sets revoked = true, writes revoked_at = clock.slot, and stores the revocation_reason_hash. The PDA is not deleted. PolicyVault's RequireValidation policy treats revoked == true as Deny(AttestationRevoked) (DenyReason code 13).
Only the original attestor can revoke an attestation it issued (UnauthorizedRevoker otherwise). v1.1+ adds an external-revoke flow with attestor-profile externals counter.
Live four-signature trace
End-to-end devnet trace, 2026-05-06 — gate denies → request → respond → gate allows:
| Step | Tx |
|---|---|
gate_payment (no attestation) → RequireValidation | 3oKW7QugBLJ7… |
request_validation | 2KbXYCF67D2f… |
respond_to_validation (creates attestation) | 67CzMS9GEt… |
gate_payment (with attestation) → Allow | dEXkCEeSn8… |
Resulting ValidationAttestation PDA: 8YKq…xt2q. Reproduce with pnpm --filter ./examples/attestor-demo run chained (~0.012 SOL total). Full trace: Verification → Chained validation.
How the gate consumes your attestation
PolicyVault's RequireValidation policy reads the ValidationAttestation at fixed byte offsets:
| Offset | Width | Field |
|---|---|---|
8 | Pubkey | subject_asset |
40 | [u8; 32] | capability_hash |
72 | Pubkey | attestor |
208 | u64 LE | expires_at (0 = never expires) |
216 | bool | revoked |
A policy with accepted_attestors = [your_pubkey, …] accepts attestations only from your key. Permissionless mode (both accepted_attestors slots zero) accepts any attestor's signature for the capability. Full read semantics: RequireValidation policy.
Validation against the Kani proof
The validation_expiry_correct proof (Kani #4, 85 sub-checks, 0.23 s) pins: an expired attestation cannot produce Allow from require_validation::evaluate. Even if all other fields match, expiry is the deciding gate.
So if you set expires_at = currentSlot + 1000 and waited 1000 slots, your attestation goes from Allow-capable to Deny(AttestationExpired) automatically — no revocation tx required. Plan attestation lifetimes accordingly. Reference: Verification → Kani proofs.
Read next
Capability namespaces
Register a new namespace, derive its hash, gate against it from PolicyVault. Plus how the v1 sybil-resistance model devolves trust to PolicyVault's accepted_attestors[].
Verification
Every load-bearing claim in AgentTrust has an independently checkable artifact — a Kani proof, a devnet tx signature, a localnet test, a cron job. This section indexes them.