Signing TRON Transactions With a Passkey
Every TRON wallet today asks a new user to do something no other app asks of t | Hanami
Signing TRON Transactions With a Passkey
Every TRON wallet today asks a new user to do something no other app asks of them: write down twelve words and never lose them. This article shows how TRON's P256VERIFY precompile lets you replace that ritual with Face ID, Touch ID, or a fingerprint using a passkey that the user's phone already knows how to back up. You will build a working smart wallet on Nile that is authorised by a passkey.
Specific setup instructions live in the reference implementation, where each workspace has its own README.
Introduction
Passkey-based signature generation, made verifiable on-chain by TRON's P256VERIFY precompile, addresses some of the longest-standing UX and security problems in crypto. By leaning on devices people already carry ( smartphones, PCs, MacBooks ) it turns familiar hardware into secure key storage, and makes blockchain applications feel less like specialist security tools.
Hardware-backed key storage such as the iOS Secure Enclave and the Android Keystore is the gold standard for protecting private keys. The key material is designed never to leave the chip, and it cannot be exported. That is an extremely strong guarantee. It also comes with a hard trade-off: if the device is lost, damaged, or wiped, the funds secured by that key may be unrecoverable.
Passkeys sit on a spectrum between those two extremes, and understanding where a given credential sits is the single most important decision you will make in this guide:
Synced passkeys offer recoverability by replicating key material across devices via cloud backup (e.g., Apple ID). While this slightly reduces isolation, it allows credentials and public keys to persist on new devices. For consumer wallets, this trade-off provides essential protection against lost hardware.
TRON's P256VERIFY accepts either kind. The precompile verifies a P-256 signature and has no idea whether the key that produced it is escrowed in a cloud or locked to one phone. That means the choice is the user's platform, and your wallet design has to account for which one it got.
What you will build
A three-part reference implementation:
1. A Next.js web app that registers a passkey and exports the signature components a TRON contract needs
2. A `P256SmartWallet` deployed on Nile, permanently bound to that passkey's public key
3. An iOS/Android app that performs the same ceremony natively against the same backend
Who is this for?
TRON dApp and wallet developers who are comfortable with Solidity and TronBox, but who have not worked with WebAuthn before. No prior passkey knowledge is assumed.
How it works
Start with the problem. A private key has to live somewhere, and every option involves a trade.
Write it down and it is only as safe as the drawer. Keep it in a hot wallet and it is only as safe as the device's software. Users lose both, constantly.
Modern phones and laptops solve this for other industries with a secure element: a dedicated chip (Apple calls its version the Secure Enclave, Android calls it the Keystore) that generates keys internally and never lets them out. Your banking app already relies on this. So why don't wallets?
The obstacle turns out to be a curve.
The curve mismatch
Every digital signature scheme is defined over a specific elliptic curve, and the two sides here picked different ones.
TRON accounts use secp256k1, the curve Bitcoin chose and Ethereum inherited.
Secure elements use secp256r1, usually written P-256. It is the curve standardised for hardware, and it is what the Secure Enclave and the Android Keystore implement.
These are not interchangeable. A signature from one will not validate under the other. So a passkey, however securely it stores its key, cannot produce anything TRON's native transaction validation accepts.
This is the gap P256VERIFY closes. It is a precompile: a function built into the virtual machine itself, exposed at a fixed address, running as native code instead of interpreted bytecode. P256VERIFY sits at 0x100 and verifies a P-256 signature on-chain.
Could you do this without a precompile? In principle. You would implement the curve arithmetic in Solidity, and it would work, and it would cost so much gas that nobody would use it. That is the practical difference a precompile makes.
The architecture that falls out of this is worth stating plainly: the passkey never controls a TRON account. It controls a contract. The contract checks P-256 signatures and acts on the user's behalf.
What a passkey actually signs
Here is where intuition fails most developers, so it is worth slowing down.
You probably assume that when a user approves an operation with Face ID, the passkey signs that operation. It does not. It signs this:
Two new terms:
authenticatorData is a structure the authenticator produces describing the ceremony: which domain it was for, whether the user was verified, a signature counter.
clientDataJSON is what the client assembled: the ceremony type, the origin, and the challenge, which is the arbitrary value the relying party asked the authenticator to sign over.
Notice what is missing. Your operation, the address you want to call, the value, the calldata, appears nowhere in that formula. It rides along base64url-encoded inside `clientDataJSON`, as the challenge.
So, now ask yourself what a valid signature proves: It proves someone holds the key. That is all. It says nothing about which operation they intended, because the operation was never the thing signed.
So in a passkey-authorised wallet, the operation digest changes role. It is not the thing being verified. It becomes the challenge that the assertion must be bound to. The contract re-encodes the digest it expects, then checks that this exact value appears at the right offset inside the signed clientDataJSON. Only then does a valid signature mean "the user approved this operation" rather than "the user signed something, once."
Drop that check and every assertion the user has ever produced on your domain authorises anything. It is the whole security of the design. In the reference repo it lives in `WebAuthn.sol`, and the offset it checks is `challengeIndex`.
The digest also folds in the wallet address and the chain id. Why bother, when the challenge binding already covers the operation? Because without it, a signature approving a transfer on one wallet would verify identically on another wallet, or on the same wallet deployed to a different chain. Committing to both closes off replay.
Step-by-Step Implementation
The reference repo has three workspaces, arranged so you can follow the whole path.
Project Structure
Part 1: The web app
The web app handles email sign-in and passkey registration in the browser, and runs entirely on `localhost` (or public https hostname if configured). Its `/p256` page is the one to look at: sign an operation and it shows you the pieces the contract needs, the signature components `r` and `s`, the public key coordinates `x` and `y`, the flags byte, and ABI-encoded calldata.
Once you finalize the README setup instructions, follow the steps below to create your passkey-bound SmartWallet:
Register your passkey
Sign in using your passkey
Open the /p256 page and export the public key coordinates `x` and `y` and UV flag by signing the digest:
Part 2: The P256 SmartWallet contract
There are four contract files.
P256.sol wraps the precompile, rejects high-s signatures, and fails if `0x100`precompile is absent.
WebAuthn.sol does the challenge binding.
P256SmartWallet.sol holds the nonce, the deadline, and the authorised call. You deploy the wallet with your passkey's `x` and `y`, then sign operations against the deployed address and Nile's chain id, `3448148188`
ChainContext.sol is used for testing purposes and does not deploy.
Deploying your Passkey bounded SmartWallet
Step 1 - Compile
Step 2 - Configure your key
Put a Nile-only private key in .env as PRIVATE_KEY_NILE. It is gitignored. Never use a key that holds mainnet funds.
Step 3 - Deploy with your passkey’s public key
Edit migrations/2_deploy_contracts.js so the constructor receives the x and y and UV flag from part 1, then
Step 4 — Sign and submit an operation
Read nonce() from deployed wallet, enter the wallet operation parameters as per the image below and Sign the digest with your registered passkey
Now, with the bare hex parameter edit the provided broadcast.js script to broadcast a real testnet transaction (make sure to fund your SmartWallet before broadcasting)
Part 3: The Mobile app
Mobile needs more setup than web, careful domain selection before production is a crucial decision.
Considerations - Native passkeys need a real HTTPS domain
A passkey is scoped to an rpId (a bare domain) , hashed into every assertion. On device, that rpId is verified by fetching https:///.well-known/apple-app-site-association over a valid certificate.
⚠️ Pick your hostname carefully A different rpId means a different credential, a different public key, and a `P256SmartWallet` that no longer matches. Choose before deploying a wallet you care about, or you will redeploy for `localhost`, again for the tunnel, and again for production.
Step 1 - Claim a static domain
Get a static domain, e.g. ngrok
Step 2 - Configure environment variables
Make sure to properly configure your env vars at apps/mobile/.env & apps/web/.env. For details follow the reference implementation README.
Step 3 - Verify before building anything
Run this before starting a native build, it takes seconds and saves a rebuild cycle.
You want JSON containing `TEAMID.com.domain.tronpasskeydemo` (iOS) and package + sha256_cert_fingerprints (Android).
Step 4 - Build a development client
npm run -w @tron-p256-passkey/mobile ios
npm run -w @tron-p256-passkey/mobile android
Note: For iOS you would need a physical device, Android emulator supports Passkeys trough emulated fingerprint (needs prior setup)
Exporting public key X, Y and tags to deploy a SmartWallet resembles the web part, signing the operation digest and exporting the bare hex parameter follows the same principle. Details can be found in the reference implementation for mobile.
Troubleshooting & Best Practices
Common Issues
Next.js moved to port 3001 The auth client follows the browser origin, but `RP_ID` and the origin list still say `3000`. Free port 3000.
RP_ID` rejected or ceremony silently fails Check for a scheme or a port. It must be a bare domain.
AASA returns 404 The `MOBILE_IOS_BUNDLE_ID` and `MOBILE_IOS_TEAM_ID` vars are not set on the web side. The route returns nothing without them.
Changed the AASA and nothing happened iOS caches it aggressively. Append `?mode=developer` to the associated-domain entry so the device fetches directly while iterating; uninstall and reinstall after changes.
Unable to resolve "expo-network"` or `"expo-web-browser". @better-auth/expo lists both as optional peer dependencies but uses import("expo-network"). Metro resolves all imports at bundle time regardless of optional status, so missing specifiers break the build, cd apps/mobile && npx expo install expo-network expo-web-browser
Best Practices
Never skip the challenge binding. Verifying the P-256 signature alone proves key possession, not operation approval. WebAuthn.verify's challengeIndex check is the security of the entire design.
Decide the hostname before deploying a wallet. The rpId propagates into the credential, the public key, and the immutable constructor argument.
Keep a Nile-only key in .env. Never a key holding mainnet funds.
Run the parity test after touching the digest on either side. Adding a field to one implementation breaks every signature silently.
Never deploy ChainContext.sol from a migration. It exists purely so the parity test can read block.chainid through the same opcode rather than hardcoding Nile's value and quietly comparing the wrong number on a local node.
Plan for recovery before production. The demo wallet has one immutable key, no rotation, no recovery. A production design needs an ownership-swap or social-recovery path.
Fee Abstraction. Someone must pay to call execute. Fee abstraction (the relayer's job) is out of scope here but is not optional in production.
Key Takeaways
TRON accounts use secp256k1 and passkeys use P-256. P256VERIFY at 0x100 is what lets the second authorise activity on the first.
A passkey signs an authenticator structure, never your payload. Your operation travels inside it as the challenge. Therefore a valid signature proves key possession, not approval. Binding the operation digest to the assertion's challenge is what supplies the missing meaning.
The digest commits to the wallet address and chain id, which is what prevents replay across wallets and chains.
rpId determines credential, credential determines public key, public key is immutable in the constructor. Decide the domain first.
Synced passkeys are recoverable, device-bound ones are not, and the contract cannot tell them apart.
Conclusion
Passkeys let a TRON wallet ask users for something they already do every day instead of asking them to safeguard a seed phrase. The precompile is live on Nile testnet and Mainnet, verification cost is minimal, and the whole path from registration through deployment to a signed operation works end to end in the reference repo.
The implementation reference repo stops short of production deliberately. One immutable key with no recovery path. No fee abstraction, since someone still has to pay energy to call `execute`. If you want to go further than this article, those are the two problems worth taking on next.
Useful Links
tron-p256-passkeys - the reference implementation, with setup instructions in each workspace README
Expo-passkey - the cross-platform passkey plugin this builds on
Nile testnet faucet - Get Nile testnet tokens(Historical earnings: For 2026Q3 (period ended 2026-04-03), Sandisk reported basic EPS of 30.82, diluted EPS of 29.42, and net income of USD 4.530 billion. For 2026Q2 (period ended 2026-01-02), basic EPS was 6.27 and diluted EPS 6.02.
Consensus expectations: For the upcoming period (event date 2026-10-29), consensus EPS estimate is 46.2722 and revenue estimate is USD 10.677 billion.)