Program ID: 2uYGW3fQUGfhrwVbkupdasXBpRPfGYBGTLUdaPTXU9vP
x0-guard is the protocol’s policy enforcement layer. It implements the SPL Transfer Hook interface, meaning every Token-2022 transfer is intercepted and validated against the sender’s AgentPolicy before the transfer can settle.
How It Works
When a Token-2022 TransferChecked instruction executes on a mint configured with x0-guard as its transfer hook, Solana automatically invokes x0-guard’s validate_transfer handler. The guard loads the agent’s policy PDA and checks:
- Policy is active —
is_active must be true
- Amount within single-transaction limit — If
max_single_transaction is set, the amount must not exceed it
- Rolling window budget — The cumulative spend in the last 24 hours plus this amount must not exceed
daily_limit
- Whitelist membership — If a whitelist is configured, the recipient must pass verification
- Minimum transfer amount — Amount must be ≥ 100 micro-units (anti-dust)
If any check fails, the transfer is rejected at the runtime level — the entire transaction reverts.
Instructions
initialize_policy
Creates a new AgentPolicy PDA for an owner-agent pair.
initialize_policy(
daily_limit: u64,
whitelist_mode: WhitelistMode,
whitelist_data: Option<Vec<u8>>,
privacy_level: PrivacyLevel
)
| Parameter | Type | Description |
|---|
daily_limit | u64 | Maximum spend in a 24-hour rolling window (micro-units) |
whitelist_mode | WhitelistMode | None, Merkle, Bloom, or Domain |
whitelist_data | Option<Vec<u8>> | Merkle root (32 bytes), Bloom filter (4096 bytes), or domain prefixes |
privacy_level | PrivacyLevel | Public or Confidential { auditor: Option<Pubkey> } |
update_policy
Updates an existing policy. Rate-limited to one update per ~5 minutes (POLICY_UPDATE_COOLDOWN_SLOTS = 750).
update_policy(
new_daily_limit: Option<u64>,
new_whitelist_mode: Option<WhitelistMode>,
new_whitelist_data: Option<Vec<u8>>,
new_privacy_level: Option<PrivacyLevel>,
new_auditor_key: Option<Pubkey>,
new_max_single_transaction: Option<u64>
)
update_agent_signer
Rotates the agent’s signing key. The old key is immediately invalidated.
update_agent_signer(new_agent_signer: Pubkey)
revoke_agent_authority
Emergency revocation — immediately invalidates the agent’s key and sets is_active = false. Only the policy owner can call this.
set_policy_active
Pauses or unpauses an agent policy without destroying it.
set_policy_active(is_active: bool)
validate_transfer
The Transfer Hook entry point. Called automatically by Token-2022 on every transfer. Not callable directly by users.
validate_transfer(amount: u64, merkle_proof: Option<Vec<[u8; 32]>>)
record_blink
Records a Blink (human-approval request) generation event. Rate-limited to MAX_BLINKS_PER_HOUR = 3.
record_blink(amount: u64, recipient: Pubkey, reason: String)
get_current_spend
View instruction — returns the current rolling window spend, remaining allowance, and oldest entry expiry.
State Accounts
AgentPolicy
pub struct AgentPolicy {
pub version: u8,
pub owner: Pubkey, // Wallet that created the policy
pub agent_signer: Pubkey, // Delegated agent hot-key
pub daily_limit: u64, // Max 24h rolling window spend
pub max_single_transaction: Option<u64>,
pub rolling_window: Vec<SpendingEntry>,
pub privacy_level: PrivacyLevel,
pub whitelist_mode: WhitelistMode,
pub whitelist_data: Vec<u8>,
pub auditor_key: Option<Pubkey>,
pub blinks_this_hour: u8,
pub blink_hour_start: i64,
pub is_active: bool,
pub bump: u8,
pub require_delegation: bool,
pub bound_token_account: Option<Pubkey>,
pub last_update_slot: u64, // Rate-limiting
}
PDA Seeds: ["agent_policy", owner, agent_signer]
SpendingEntry
Each entry in the rolling window tracks one transfer:
pub struct SpendingEntry {
pub amount: u64, // Transfer amount in micro-units
pub timestamp: i64, // Unix timestamp of transfer
}
Rolling Window Algorithm
The spend limit uses a sliding 24-hour window rather than a fixed daily reset:
- On each transfer, expired entries (older than
ROLLING_WINDOW_SECONDS = 86,400) are pruned
- The sum of remaining entries is computed as
current_spend
- If
current_spend + amount > daily_limit, the transfer is rejected
- On success, a new
SpendingEntry is appended
- Maximum entries capped at
MAX_ROLLING_WINDOW_ENTRIES = 144 (~one per 10 minutes)
This prevents the “midnight boundary” attack where an agent spends the daily limit at 23:59 and again at 00:01.
Whitelist Modes
No whitelist — the agent can transfer to any recipient.
A Merkle root (32 bytes) is stored on-chain. Each transfer must include a Merkle proof (up to 14 levels deep) proving the recipient’s address is a leaf in the tree. Supports up to 10,000 addresses.const root = buildMerkleRoot(allowedAddresses);
const proof = generateMerkleProof(allowedAddresses, recipientIndex);
A Bloom filter (4,096 bytes, 7 hash functions) is stored on-chain. Provides probabilistic membership testing with ~1% false positive rate for ~1,000 items. No false negatives.const filter = createBloomFilter(allowedAddresses);
// On-chain: checkBloomFilter(filter, recipient)
Up to 100 domain prefixes (8 bytes each) are stored. The recipient’s address bytes are checked against these prefixes. Useful for restricting transfers to addresses within a known program’s PDA space.
Privacy Levels
| Level | Behavior |
|---|
Public | Standard Token-2022 transfer. Amount and parties are visible on-chain. |
Confidential | Uses Token-2022 ConfidentialTransfer extension. Amounts are encrypted with Twisted ElGamal. Optional auditor pubkey can decrypt for compliance. |
Events Emitted
| Event | When |
|---|
PolicyCreated | New policy initialized |
PolicyUpdated | Policy parameters changed |
AgentRevoked | Agent authority revoked |
TransferValidated | Transfer passed policy check |
TransferRejected | Transfer failed policy check |
BlinkGenerated | Blink approval request recorded |
WhitelistUpdated | Whitelist data changed |
Last modified on February 8, 2026