AuditAnchor is the keystone of Regent’s tamper-evidence claim: every audit event ever
ingested can be proven — or disproven — against an account in this program.
It holds two things. A chain: one account with a running head over every batch root in
order, so the audit trail is provably complete and unaltered, not merely individually
hashed. And epochs: periodic Merkle roots over ranges of that chain, which keep proofs
short and give each range a Bitcoin timestamp. The original per-batch account
(anchor_batch) still runs on devnet so that every account written since April 2026 and
every published recipe keeps working.
| Program ID (devnet) | 8N1PpbJZKmvJjG86XWpP82XrWzp8HY5FHZuzyQTgjJas |
| Framework | Anchor 0.31.1 |
| Upgradeable? | On devnet, yes — the upgrade authority 47BBdKL1SwY7auTN4mQpnQAFX14bCjs7qie9R34FrXxc is held by Regent so fixes can ship during the pilot phase. ADR-010 commits the core programs to be non-upgradeable on mainnet |
| Source | regent-protocol/regent-solana-programs → programs/audit-anchor |
Why a separate program
Agents and mandates are bounded — there are some number of agents, some number of mandates, and each has a lifecycle. Audit events are unbounded: every action of every agent produces one. Batching them into Merkle roots is the only way to keep on-chain costs constant per event regardless of throughput.
Account layout
#[account]
pub struct Batch {
pub batch_id: [u8; 32], // the batch UUID's 16 bytes, left-padded with zeros
pub merkle_root: [u8; 32],
pub event_count: u32,
pub anchored_at: i64, // unix seconds, set by the program from the cluster clock
pub bump: u8,
}PDA seeds: [b"batch", batch_id].
The account is tiny — 85 bytes including the 8-byte discriminator — so anchoring is cheap even at scale.
Instructions
anchor_batch
Writes a new Batch account. Once written, an account is immutable — there’s no update_batch instruction by design. The program rejects an empty batch (event_count == 0) and an all-zero root.
| Arg | Type |
|---|---|
batch_id | [u8; 32] |
merkle_root | [u8; 32] |
event_count | u32 |
Off-chain, api-audit accumulates events until either:
- the batch reaches its size threshold (100 events), or
- the auto-seal timer fires (seconds, configured per deployment)
…then computes the Merkle root over the events’ payload hashes (SHA-256, sha256(left ‖ right), an odd node paired with itself) and hands the sealed batch to blockchain-worker, which calls anchor_batch. The transaction signature is recorded on every event in the batch as solana_tx.
append_batch
Appends one batch root to the audit chain — the single account that makes the series of batches provable, not just each batch individually.
| Arg | Type | Notes |
|---|---|---|
batch_root | [u8; 32] | the batch’s Merkle root |
seq | u64 | must equal chain.seq + 1 |
prev_head | [u8; 32] | must equal chain.head — the caller states which history it extends |
#[account]
pub struct AuditChain { // seeds = [b"chain"], created once
pub head: [u8; 32], // sha256(head_prev ‖ batch_root ‖ seq_le8); zeros before the first
pub seq: u64, // latest appended batch; the program requires seq == chain.seq + 1
pub last_appended_at: i64,
pub epoch_count: u64,
pub last_epoch_end_seq: u64,
pub bump: u8,
}The two guards are the point: a gap in seq is refused on chain, so a batch cannot be
skipped, and prev_head means a client whose own record of the chain has drifted is refused
rather than quietly extending a different history. Any change to an earlier batch changes
every head after it. Cost is one transaction fee
and no rent, which is why this replaces the per-batch account on mainnet — at one batch
every 30 seconds the accounts cost roughly $379 a day in permanent rent, the chain about
$1.90.
anchor_epoch
Records a Merkle root over the batch roots of a contiguous sequence range, so a single event is proven with two short Merkle paths instead of a replay of the chain.
| Arg | Type |
|---|---|
epoch_index | u64 (1-based; PDA seed, little-endian) |
first_seq | u64 |
last_seq | u64 |
merkle_root | [u8; 32] |
#[account]
pub struct Epoch { // seeds = [b"epoch", epoch_index_le8]
pub epoch_index: u64,
pub first_seq: u64, // must be last_epoch_end_seq + 1
pub last_seq: u64, // must be <= chain.seq
pub merkle_root: [u8; 32],
pub anchored_at: i64,
pub bump: u8,
}Epochs are sequential and contiguous by construction, so they tile the chain with no gaps
and no overlaps. Each epoch root is also stamped into Bitcoin through OpenTimestamps; the
proof is served at /v1/audit/epochs/{n}/ots.
init_chain
Creates the AuditChain account. Run once per deployment by the operator.
transfer_authority, init_admin, transfer_admin
config.authority is the operator — the hot key the anchoring service signs with. A
separate AdminConfig PDA (seeds [b"admin"]) holds the admin, a multisig on mainnet,
and only the admin can rotate the operator. init_admin is the one-time bootstrap;
transfer_admin hands the admin role on. A compromised operator key can therefore write
records — which the trail exposes — but cannot change who is in charge.
How proofs work
Once a batch is sealed, every event in it has a Merkle proof — a list of sibling hashes that lets anyone verify the event without downloading the rest of the batch.
If you want to prove that “event 1” was in this batch:
- Hash event 1’s canonical payload → leaf hash
H - Combine with sibling
G(provided in the proof) → get parentD - Combine
Dwith siblingC(provided) → get parentA - Combine
Awith siblingB(provided) → rootR - Compare
Ragainst the on-chainmerkle_root
Match → event is provably in the batch. No match → either the event was forged, or the batch has been tampered with off-chain.
Why this matters
Without on-chain anchoring, an audit log can be rewritten silently. With anchoring:
- Adding a fake event after the batch is sealed produces a different root → mismatch
- Deleting a real event produces a different root → mismatch
- Modifying any event’s payload produces a different root → mismatch
The only way to forge convincingly would be to issue a new on-chain transaction with the modified root — which leaves a fingerprint on Solana that anyone can see (and that would conflict with the original transaction).
Reading a batch off-chain
// batch_id on-chain = the UUID's 16 bytes, left-padded to 32
const batchIdBytes = Buffer.concat([Buffer.alloc(16), Buffer.from(batchId.replace(/-/g, ""), "hex")]);
const [batchPda] = PublicKey.findProgramAddressSync(
[Buffer.from("batch"), batchIdBytes],
programId,
);
const batch = await program.account.batch.fetch(batchPda);
console.log("Merkle root:", Buffer.from(batch.merkleRoot).toString("hex"));
console.log("Event count:", batch.eventCount);
console.log("Anchored at:", new Date(batch.anchoredAt * 1000));To verify an individual event, see Verifying on-chain.