AgentTrust
AgentTrust
Integration guides

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:

StepTx
gate_payment (no attestation) → RequireValidation3oKW7QugBLJ7…
request_validation2KbXYCF67D2f…
respond_to_validation (creates attestation)67CzMS9GEt…
gate_payment (with attestation) → AllowdEXkCEeSn8…

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:

OffsetWidthField
8Pubkeysubject_asset
40[u8; 32]capability_hash
72Pubkeyattestor
208u64 LEexpires_at (0 = never expires)
216boolrevoked

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.

On this page

⌘I