The Action Codes Protocol is a lightweight way to prove intent and authorize actions across blockchains, apps, and wallets. Instead of heavy signature popups or complex flows, it uses short-lived one-time codes derived from canonical cryptographic messages.
- Secure intent binding – the code is cryptographically tied to a wallet/public key.
- Fast verification – relayers/servers can validate in microseconds (~3ms per verification).
- Cross-chain support – adapters handle chain-specific quirks (currently supporting Solana)
- Simple dev UX – just generate → sign → verify.
- Issuer support – allows separation between code issuer and intent owner for advanced use cases.
What's new in v2 (compared to v1)
- Now we use Bun as core library. We are down to ~3ms per code signature verification on commodity hardware.
- Canonical Messages only → No ambiguity. Codes are always derived from a canonical serialization (serializeCanonical).
- No AIPs (Action Codes Improvement Proposals) → Overkill for lightweight protocol.
- Chain Adapters simplified → They don't enforce business rules; they just provide utilities:
- createProtocolMetaIx (for attaching metadata)
- parseMeta / verifyTransactionMatchesCode (for checking integrity)
- verifyTransactionSignedByIntentOwner (checks both
intandisssignatures when present).
- Errors are typed → Clear ProtocolError.* categories instead of generic fails.
-
Action Codes
- A short-lived, one-time code bound to a wallet/public key.
- Generated using HMAC/HKDF and canonical serialization.
-
Protocol Meta
- The "payload" carried with transactions to tie them to action codes.
- Versioned, deterministic, size-limited (max 512 bytes).
- Fields:
ver(version),id(code hash),int(intent owner),iss(issuer, optional),p(params, optional). - When
issfield is present, both issuer and intent owner must sign the transaction. - Perfect for attaching to transactions or off-chain messages for tracing.
-
Canonical Message
- A deterministic JSON serialization of (pubkey, code, timestamp, optional secret)
- Always signed by the user's wallet.
- Prevents replay / tampering.
The Action Codes Protocol supports two main strategies for generating and validating codes:
The Wallet Strategy is the simplest approach where codes are generated directly from a user's wallet.
import { ActionCodesProtocol } from '@actioncodes/protocol';
import { SolanaAdapter } from '@actioncodes/protocol';
// Initialize protocol
const protocol = new ActionCodesProtocol({
codeLength: 8, // Code length (6-24 digits)
ttlMs: 120000, // Time to live (2 minutes)
clockSkewMs: 5000 // Clock skew tolerance (5 seconds)
});
// Register Solana adapter
protocol.registerAdapter('solana', new SolanaAdapter());
// 1. Generate code with wallet strategy
const signFn = async (message: Uint8Array, chain: string) => {
// Sign the canonical message with user's wallet
const signature = await userWallet.signMessage(message);
return signature; // Returns base58-encoded signature
};
const actionCode = await protocol.generate(
"wallet",
userWallet.publicKey.toString(),
"solana",
signFn
);
// 2. Validate code
protocol.validate("wallet", actionCode);
// Throws ProtocolError if validation fails- Signature-based security - Codes require a valid signature over the canonical message (prevents public key + timestamp attacks)
- Direct wallet binding - Codes are cryptographically tied to the user's public key
- HMAC-based generation - Uses signature as entropy source for secure code generation
- Immediate validation - No delegation certificates needed
- Perfect for - Direct user interactions, simple authentication flows, transaction authorization
- Signature verification - All codes require a valid signature over the canonical message
- Public key + timestamp attack prevention - Signatures prevent attackers from generating codes with just public key + timestamp
- HMAC-based entropy - Codes use HMAC-SHA256 with signature as key, ensuring cryptographic security
- Codes are bound to the specific public key and timestamp
- Time-based expiration prevents replay attacks
- Canonical message signing ensures integrity
The Delegation Strategy allows users to pre-authorize actions through delegation proofs, enabling more complex workflows like relayer services.
import { serializeDelegationProof } from '@actioncodes/protocol';
// User creates a delegation proof
const delegationProof = {
walletPubkey: userWallet.publicKey.toString(),
delegatedPubkey: delegatedKeypair.publicKey.toString(),
chain: "solana",
expiresAt: Date.now() + 3600000, // 1 hour expiration
signature: "" // Will be set after signing
};
// User signs the delegation proof
const delegationMessage = serializeDelegationProof(delegationProof);
const delegationSignature = await userWallet.signMessage(delegationMessage);
delegationProof.signature = delegationSignature;// Sign function for delegated keypair
const delegatedSignFn = async (message: Uint8Array, chain: string) => {
const signature = await delegatedKeypair.signMessage(message);
return signature;
};
// Generate code using delegation strategy
const delegatedActionCode = await protocol.generate(
"delegation",
delegationProof,
"solana",
delegatedSignFn
);// Validate the delegated code
protocol.validate("delegation", delegatedActionCode);
// Throws ProtocolError if validation fails- Pre-authorization - Users can authorize actions for a limited time
- Relayer support - Third parties can validate codes without generating them
- Proof-based - Codes are bound to specific delegation proofs
- Time-limited - Delegation proofs have expiration times
- Perfect for - Relayer services, automated systems, complex workflows
- Code-Proof Binding - Codes are cryptographically bound to their specific delegation proof
- Signature Verification - Delegation proof signatures are verified using chain adapters
- Dual Signature Requirement - Both wallet signature (on proof) and delegated signature (on code) are required
- Cross-Proof Protection - Codes from one delegation proof cannot be used with another
- Relayer Security - Relayers can validate codes but cannot generate them without the delegated keypair's signature
-
Stolen Delegation Proofs are Limited
- Delegation proofs contain public data (pubkeys, expiration, chain)
- They cannot be used to generate codes without the delegated keypair's private key
- The proof signature prevents tampering but doesn't enable code generation
-
Stolen Signatures Cannot Create Valid Codes
- Even if an attacker steals a signature, they cannot create valid codes
- Codes are bound to the ENTIRE delegation proof (not just the signature)
- Different proof data = different code = validation failure
-
Relayer Code Generation Prevention
- Relayers cannot generate codes even with public delegation proof data
- Codes require signatures from the delegated keypair (private asset)
- Only holders of the delegated keypair can generate valid codes
-
Code-Proof Binding
- Codes are cryptographically linked to their specific delegation proof
- Cross-proof attacks are impossible
- Each delegation proof produces unique codes
interface DelegationProof {
walletPubkey: string; // Original wallet's public key
delegatedPubkey: string; // Delegated keypair's public key
chain: string; // Target blockchain
expiresAt: number; // Expiration timestamp
signature: string; // Wallet's signature of the delegation proof
}interface DelegatedActionCode {
code: string; // The actual action code
pubkey: string; // Delegated pubkey (who signs the code)
timestamp: number; // Generation timestamp
expiresAt: number; // Code expiration
signature: string; // Signature from delegated keypair
chain: string; // Target blockchain
delegationProof: DelegationProof; // The delegation proof
}// User logs into a dApp
const signFn = async (message: Uint8Array, chain: string) => {
return await userWallet.signMessage(message);
};
const actionCode = await protocol.generate(
"wallet",
userWallet.publicKey.toString(),
"solana",
signFn
);
// dApp validates the code
protocol.validate("wallet", actionCode);import { buildProtocolMeta, codeHash } from '@actioncodes/protocol';
// User authorizes a specific transaction
const signFn = async (message: Uint8Array, chain: string) => {
return await userWallet.signMessage(message);
};
const actionCode = await protocol.generate(
"wallet",
userWallet.publicKey.toString(),
"solana",
signFn
);
// Relayer validates before executing
protocol.validate("wallet", actionCode);
// Attach protocol meta to transaction
const adapter = protocol.getAdapter("solana") as SolanaAdapter;
const meta = buildProtocolMeta({
ver: 2,
id: codeHash(actionCode.code),
int: actionCode.pubkey,
});
const txWithMeta = SolanaAdapter.attachProtocolMeta(transactionBase64, meta);
// Verify transaction is signed by the intended owner
adapter.verifyTransactionSignedByIntentOwner(txWithMeta);// User creates delegation proof for relayer
const delegationProof = {
walletPubkey: userWallet.publicKey.toString(),
delegatedPubkey: relayerKeypair.publicKey.toString(),
chain: "solana",
expiresAt: Date.now() + 3600000, // 1 hour
signature: await signDelegationProof(delegationProof)
};
// Relayer can validate codes but not generate them
// User generates codes that relayer can validate
const delegatedSignFn = async (message: Uint8Array, chain: string) => {
return await relayerKeypair.signMessage(message);
};
const actionCode = await protocol.generate(
"delegation",
delegationProof,
"solana",
delegatedSignFn
);
// Relayer validates the code
protocol.validate("delegation", actionCode);// User authorizes trading bot for 24 hours
const delegationProof = {
walletPubkey: userWallet.publicKey.toString(),
delegatedPubkey: botKeypair.publicKey.toString(),
chain: "solana",
expiresAt: Date.now() + 86400000, // 24 hours
signature: await signDelegationProof(delegationProof)
};
// Bot generates codes using delegated keypair
const botSignFn = async (message: Uint8Array, chain: string) => {
return await botKeypair.signMessage(message);
};
const tradeCode = await protocol.generate(
"delegation",
delegationProof,
"solana",
botSignFn
);
// Bot executes trade with this code-
Cryptographic Binding
- Codes are mathematically tied to specific public keys
- Impossible to forge without the private key
-
Time-Limited Validity
- Codes expire automatically
- Prevents replay attacks
-
One-Time Use
- Each code is unique and time-bound
- Cannot be reused
-
Delegation Security
- Delegation proofs are cryptographically signed
- Codes are bound to specific delegation proofs
- Cross-proof attacks are impossible
-
Delegation Proof Expiration
- Set appropriate expiration times for delegation proofs
- Shorter expiration = higher security
- Longer expiration = better UX
-
Relayer Security
- Only trust relayers with valid delegation proofs
- Never share private keys with relayers
- Monitor relayer behavior
-
Issuer Field Usage
- Use
issfield when issuer and intent owner are different entities - Ensure both signatures are present when
issis specified - Omit
isswhen it's the same asintto save space
- Use
-
Code Validation
- Always validate codes server-side
- Check expiration times
- Verify the binding to the correct public key
-
Protocol Meta with Issuer
- Use
issfield when issuer differs from intent owner - When
issis present, ensure transaction is signed by both - If
issequalsint, it's automatically omitted (optimization)
- Use
- Code Generation: ~1ms per code
- Code Validation: ~3ms per validation
- Memory Usage: Minimal (no state storage required)
- Network: No network calls required for validation
# Install
npm install @actioncodes/protocol
# Basic usage
import { ActionCodesProtocol } from '@actioncodes/protocol';
import { SolanaAdapter } from '@actioncodes/protocol';
// Initialize protocol
const protocol = new ActionCodesProtocol({
codeLength: 8, // Code length (6-24 digits)
ttlMs: 120000, // Time to live (2 minutes)
clockSkewMs: 5000 // Clock skew tolerance (5 seconds)
});
// Register Solana adapter
protocol.registerAdapter('solana', new SolanaAdapter());
// 1. Sign function for wallet
const signFn = async (message: Uint8Array, chain: string) => {
// Sign the canonical message with your wallet
const signature = await yourWallet.signMessage(message);
return signature; // Returns base58-encoded signature
};
// 2. Generate a code
const actionCode = await protocol.generate(
"wallet",
yourWallet.publicKey.toString(),
"solana",
signFn
);
// 3. Validate a code
protocol.validate("wallet", actionCode);
// Throws ProtocolError if validation failsWhen you need to separate the issuer (who created the code) from the intent owner (who will use it):
import { buildProtocolMeta } from '@actioncodes/protocol';
// Create protocol meta with issuer
const meta = buildProtocolMeta({
ver: 2,
id: codeHash(actionCode.code),
int: intentOwnerPubkey, // Who will use the code
iss: issuerPubkey, // Who issued the code (optional)
});
// If iss is same as int, it's automatically omitted to save space
// If iss is present, transaction must be signed by BOTH int and issAction Codes Protocol aim to be the OTP protocol for blockchains but allowing more than authentication: a simple, universal interaction layer usable across apps, chains, and eventually banks/CBDCs interacting with blockchains.