Skip to Content
Architecture

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 += 1

Intent hash

The 32-byte challenge that binds the passkey signature to the exact authorized action. Computed identically in packages/sdk/src/react/intent.tshashIntent() and programs/guard/src/verify.rscompute_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.

FieldBytesNotes
Anchor discriminator8
owner32wallet pubkey
key_kind1
pubkey_bytes4 + ≤33compressed P-256
credential_id4 + ≤128WebAuthn credential ID
enabled1constraint-checked before enforce()
nonce8u64 LE, increments on each approval

Total: 219 bytes allocated.

Last updated on