Architecture
Trana has three layers. The Solana runtime ties them together: all three execute in a single transaction or none of them do.
┌─────────────────────────────────────────────────────┐
│ dApp / Frontend (React) │
└──────────────────┬──────────────────────────────────┘
│ @tranaprotocol/guard-sdk
┌──────────────────▼──────────────────────────────────┐
│ packages/sdk │
│ Intent hash │ WebAuthn │ Tx builder │
└──────────────────┬──────────────────────────────────┘
│ Signed transaction
┌──────────────────▼──────────────────────────────────┐
│ Solana Runtime │
│ ix[N-2] secp256r1 precompile (SIMD-0075) │
│ ix[N-1] guard::record_proof (data carrier) │
│ ix[N] your_program::action → guard::enforce() │
└─────────────────────────────────────────────────────┘Components
programs/guard: the authorization primitive
One deployed program. Three instructions.
register_two_fa writes a P-256 public key and credential ID into a PDA (["2fa", wallet_pubkey]). Re-registering with a new device updates the key in place without resetting the nonce.
record_proof is a data carrier. It holds WebAuthn binding bytes (authenticatorData, clientDataJSON, expiry, policy, cluster) in instruction data and modifies no accounts.
enforce is called by your program via CPI. It reads the two preceding instructions from the Instructions sysvar, verifies the P-256 signature against the registry pubkey, checks the intent hash, and increments the nonce. If anything fails, the whole transaction reverts.
packages/sdk: TypeScript client
packages/sdk/src/
├── secp256r1.ts buildSecp256r1Ix, buildWebAuthnMessage, buildRecordProofIx
├── utils.ts sha256, decodeParamsU64
├── policy.ts Policy type, policyString(), evaluatePolicy()
├── requirement.ts checkRequirement() — client-side condition check
└── react/
├── intent.ts buildIntent(), hashIntent(), intentFromInstruction()
├── useTrana.ts authorizeAndSend() — the main integration hook
├── provider.tsx TranaProvider — context and deferred promise hub
├── modal.tsx TranaModal — registration, confirmation, approval, error
├── detector.ts detectEnforcement() — simulation-based policy detection
└── registry.ts fetchRegistry(), findRegistryPda()Authorization flow
Device Client Solana
────── ────── ──────
1. Simulate the transaction to detect which policy fires
(reads "trana.limit" / "trana.require" etc. from logs)
2. Fetch registry PDA ──────────────────────────► nonce, pubkey
3. Compute intent hash:
SHA-256(version, domain, cluster, wallet, guard,
target, policy, discriminator,
accounts_hash, params_hash, nonce, expiry)
4. Show confirmation modal — user reviews action details
5. navigator.credentials.get(challenge = intentHash)
◄── Touch ID / Face ID
6. Compact DER → 64-byte r‖s (low-S normalized)
7. Build transaction:
ix[N-2]: secp256r1Ix(pubkey, sig, authData ‖ SHA-256(cdJSON))
ix[N-1]: recordProofIx(authData, cdJSON, expiry, policy)
ix[N]: your instruction
8. Sign with wallet ────────────────────────────► Solana runtime
executes ix[N]:
your_program::action()
└─ guard::enforce()
verify P-256 sig
check intent hash
nonce += 1Intent hash
The 32-byte challenge that binds the passkey signature to the exact authorized action. Computed identically in packages/sdk/src/react/intent.ts → hashIntent() and programs/guard/src/verify.rs → compute_intent_hash().
SHA-256(
u8 version = 1
u16LE + utf8 "trana:v1"
u16LE + utf8 cluster
32 bytes wallet pubkey
32 bytes guard program ID
32 bytes target program ID
u16LE + utf8 policy_id
8 bytes instruction discriminator
32 bytes SHA-256(all account pubkeys concatenated)
32 bytes SHA-256(instruction params)
u64LE nonce (from registry PDA)
i64LE expiry unix timestamp
)Three things prevent replay. The nonce increments on-chain after every approval, so a captured proof is worthless after the next transaction. The expiry timestamp rejects any proof where clock.unix_timestamp >= expiry. Exact binding means any change to accounts, parameters, or cluster produces a different hash and fails verification.
The expected cluster ("mainnet-beta", "devnet", "localnet") is a compile-time constant baked into the program binary via Cargo feature flags, not in a mutable config account. A proof signed for devnet will fail with ClusterMismatch when submitted to a mainnet binary, even if both binaries share the same program ID.
Registry PDA
Seeds: ["2fa", wallet_pubkey], program: guard.
| Field | Bytes | Notes |
|---|---|---|
| Anchor discriminator | 8 | |
| owner | 32 | wallet pubkey |
| key_kind | 1 | |
| pubkey_bytes | 4 + ≤33 | compressed P-256 |
| credential_id | 4 + ≤128 | WebAuthn credential ID |
| enabled | 1 | constraint-checked before enforce() |
| nonce | 8 | u64 LE, increments on each approval |
Total: 219 bytes allocated.