An XRP account that only does what you allow — and can't be drained, even by whoever runs it.
Keyless gives an XRP wallet a signing key that is born inside a Flare Confidential Compute TEE and never leaves it. That key will only ever sign a payment an on-chain policy on Flare has already approved. Steal the browser control key, compromise the app, or own the machine the enclave runs on — none of it lets you move funds anywhere the rules forbid. There is no exportable key to take.
The rules aren't for you. They're for whoever gets in.
Try it — no wallet, no signup → · Demo video →
Read from KeylessAccounts events on 2026-08-13 — every number below is on-chain, not self-reported.
| Accounts created | 77, across 26 distinct owners |
| XRPL keys generated inside the enclave | 70 |
| Accounts with a policy attached | 71 |
| Payments a contract had to approve first | 72, across 29 accounts |
| XRP moved under policy | 2,882.5 |
| Policies locked forever (irreversible) | 3 |
| Contract tests | 78 passing (forge test) |
Holding XRP is all-or-nothing. Whoever has the private key can send anything, anywhere — so:
- A stolen key is a drained account. Phishing, malware, a leaked backup: one compromise is total loss. There is no "this key may only pay my exchange" setting on a raw wallet.
- Bots, agents and services must hold live keys. An automated strategy or an AI agent needs to sign on its own, which means a hot key that empties the account if it leaks. That risk is why few people run them.
- Teams face the same choice. Either one person holds the key, or you bolt on multisig ops. Neither expresses a rule as simple as "only pay approved addresses, max 10 XRP a day."
- XRPL can't fix this itself. The XRP Ledger has no smart contracts — there is nowhere on XRP to enforce a spending policy. Historically the only options were "trust the key holder" or "trust the operator's server", and a server that can promise rules can also be changed.
The missing primitive: a key that is provably unextractable and provably obedient to a public rule.
Keyless puts the policy on Flare and binds an XRPL signing key to it inside a Confidential Compute TEE:
- The signing key is generated inside the enclave and never leaves — nobody, including us or the machine operator, has ever seen it.
- Every payment must pass an on-chain policy contract on Flare before the enclave will sign it.
- Anyone can verify the binding on-chain: which contract commands the enclave, and which exact code hash the enclave runs.
The trust boundary moves from "trust the key holder" to "read the contract and read the registered code hash." XRPL settles; Flare decides whether it's allowed; the TEE holds a key that can't be stolen and won't disobey.
Every link is verifiable on-chain or in open source.
- The key is born in the enclave.
createWalletsends anINITinstruction, and the enclave answers by generating a fresh XRPL key from its own entropy. No key is ever imported, and only the resulting address leaves. (Flare's referencefce-signdoes the opposite — it imports an operator-supplied key. We deliberately have no such code path.) - The code hash is pinned on-chain. The Keyless FCC extension (id 65645 on Coston2) registers the enclave image's code hash and a governance signer-set. A machine can only join by attesting to that exact hash under that governance — so "trust the operator" becomes "read the registered code hash."
- One contract is the only boss.
KeylessAccountsis the extension's soleinstructionsSender(isBound = true). The enclave acts on instructions from that contract and nothing else. - A policy gates every signature.
pay()runs the wallet's rule before the instruction is sent. If the rule reverts, the enclave never sees the payment.
| Op | What it does |
|---|---|
INIT |
Generate a new XRPL keypair inside the enclave; return only the address |
XRPSEND |
Construct and sign one XRPL Payment from (recipient, amount, paymentReference) |
XRPSEND never signs a transaction it is handed — it builds one from fields the contract already
approved. That's what makes "it can't sign outside the rules" a fact rather than a promise, and it's why
adding a policy never touches the enclave.
function pay(bytes32 walletId, string calldata recipient, uint256 amount, bytes32 paymentReference)
external payable
{
address rule = ruleOf[walletId];
if (rule == address(0)) revert NoRule();
IKeylessRule(rule).authorize(walletId, recipient, amount, paymentReference); // reverts if forbidden
instructionId = _send(OP_PAY, abi.encode(XrplPayment(walletId, recipient, amount, paymentReference)));
}There is no msg.sender check. The rule is the gate, not the caller — which is why you can hand an
agent an account id and nothing else, and why a keeper can trigger a scheduled or proven payment without
being trusted.
That is only safe because of an invariant every rule must hold: a rule must pin where the money can go,
because nothing pins who can ask. (One configuration once broke it; see
SECURITY_NOTES.md #0.)
32 bytes. The top 4 are the XRPL destination tag, big-endian; the rest is a memo. The enclave sets the
ledger's DestinationTag from those bytes, and ExchangeRule compares them against the tag pinned to the
recipient — so a CEX deposit is bound to (address, tag) as a pair. Sending to the right exchange under
someone else's tag is refused.
For FXRP the same field carries a Flare Smart Account instruction id in byte 0.
flowchart TD
subgraph Browser["Browser — you"]
CK["Control key<br/>(edits rules, requests payments)"]
end
subgraph Flare["Flare / Coston2 — decides what may be signed"]
KA["KeylessAccounts<br/>(multi-tenant keyring manager)"]
RULES["Rule modules<br/>Exchange · Spending limit · Scheduled<br/>Conditional · FXRP"]
REG["Flare TEE manager (diamond)<br/>ext 65645 · code hash · governance"]
FDC["Flare Data Connector<br/>(attests real-world facts)"]
FSA["Flare Smart Accounts<br/>+ FAssets (FXRP)"]
end
subgraph TEE["Flare Confidential Compute — does the signing"]
ENC["Enclave<br/>1 XRPL key per wallet · never exported"]
end
subgraph XRP["XRPL — settles"]
LEDGER["XRP Ledger"]
end
CK -->|"setRule / configure"| RULES
ANYONE["anyone — an agent, a keeper, a script"] -->|"pay(walletId, to, amount, ref)"| KA
CK --> KA
KA -->|"authorize() — reverts if policy forbids"| RULES
RULES -->|"verifyWeb2Json(proof)"| FDC
KA -->|"getRandomTeeIds(65645) + sendInstructions"| REG
REG -->|"INIT / XRPSEND"| ENC
ENC -->|"signs + submits the allowed payment"| LEDGER
LEDGER -->|"tagged mint · FSA instructions"| FSA
Four Flare systems, all load-bearing: Confidential Compute holds the key, the Data Connector turns real-world facts into on-chain truth, FAssets moves XRP to Flare as FXRP, and Smart Accounts run the vault operations. XRPL settles.
Each policy is one small Solidity contract. The enclave never changes when a policy is added.
| Policy | What it enforces | For |
|---|---|---|
| Exchange & allowlist | Pay only approved addresses, each optionally pinned to an exact destination tag, plus a per-payment cap | Exchange-only and cold-storage accounts |
| Spending limit | An approved list + a cap per rolling window, calendar period, or one-off budget | Agents, apps, allowances |
| Scheduled payments | Fixed payee, fixed amount, fixed calendar slot, capped number of runs. Missed runs are skipped, never accrued | Payroll, standing orders, DCA |
| Conditional | Pay only once Flare's Data Connector attests a real-world fact. Refuses every recipient — payee and fallback — until proven | Escrow, bounties, parametric insurance |
| FXRP | Mint XRP → FXRP into an account-derived Smart Account, run whitelisted vault operations, redeem home, cash out to approved payees | Yield on idle XRP |
Policies are lockable: lockRule(walletId) one-way freezes the rule pointer and its configuration, so
a later-stolen control key can't widen it.
createWallet(salt) → INIT → the enclave generates an XRPL keypair from its own entropy → reports back
only the address → xrplAddressOf(walletId). The policy is chosen before INIT fires, so no account
has ever existed without a rule.
pay() → the rule's authorize() runs on-chain → if it reverts, the enclave is never asked → otherwise
XRPSEND → the enclave builds a Payment, signs it, submits it to XRPL.
configure() pins the whole Web2Json request — url, query params, jq transform and ABI signature — into
the rule. A watcher (anyone can run it) reads the API, requests an attestation, waits for the voting round,
and calls release(proof). ConditionalRule verifies the proof against the pinned request, so a proof
of a different API returning the same value cannot release it — the vulnerability that killed the earlier
FdcEscrowRule.
Only three shapes of payment are permitted, keyed by who the XRPL recipient is.
Mint. An XRPL payment to the FAssets Core Vault carrying a 32-byte direct-minting reference:
0x4642505266410018 · 00000000 · the 20-byte Flare address to credit. That address must equal
getPersonalAccount(xrplAddressOf(walletId)): your own Smart Account, computed on-chain, not
configurable. A stolen control key cannot repoint the mint, because there is no setter to repoint.
Vault operations. Every Flare Smart Account instruction is an XRPL payment to FSA's provider wallet
whose reference carries the command in byte 0. The rule allows exactly 0x02 (redeem FXRP back to
XRP), 0x11–0x13 (Firelight deposit / redeem / claim) and 0x21–0x23 (Upshift deposit / requestRedeem /
claim). Everything else reverts — and 0x01, transfer FXRP to another address, is refused explicitly.
That one line is the whole guarantee: no instruction this account can issue moves the position to anyone
else. The XRPL payment that carries an instruction is a messaging cost, not the value being moved, so it
is capped separately — 10 XRP on the live rule. Minting is deliberately uncapped: a bigger mint just
credits more FXRP to your own account.
Because the list is closed rather than a blocklist, FSA's later custom instructions (0xFF/0xFE) were
refused the day they shipped, with no change on our side.
Cash out. Note what is not relaxed: FXRP still cannot be transferred out. The only exit is to redeem home to XRP first — a step the rule can see — and only then pay an XRPL address the owner allowlisted. An earlier version had no exit at all, and a tester put it exactly right: value that can enter and never leave is a trap, not a guarantee.
All verified live on 2026-08-13.
| Component | Address |
|---|---|
| KeylessAccounts | 0x57eb332D…19979 |
| Flare TEE manager (diamond) | 0x1a9C4A0f…618aE |
| FCC extension id | 65645 · isBound = true |
| Exchange & allowlist | 0x2E5e2A10…2d7e4 |
| Spending limit | 0x51Cc5c71…73710 |
| Conditional (FDC) | 0x2d8517BC…19E77 |
| Scheduled payments | 0x683bDB59…7Be84 |
| FXRP round trip | 0xAABAEA1D…97482 |
| Flare Smart Accounts (diamond) | 0x434936d4…AD37c |
| AssetManager FXRP | 0xc1Ca88b9…bDFA |
The attested TEE machine changes when the enclave is redeployed; look up the current one through the manager diamond rather than trusting a hardcoded address here.
| Adversary holds… | Can they drain the account? |
|---|---|
| The browser control key | No (if the rule is locked). They can only call pay to policy-approved recipients; lockRule freezes the rule pointer + config so they can't repoint to a permissive rule. On an unlocked account a stolen control key can setRule→pay, so lock before funding. |
| The app / frontend | No. It can only ask for payments; the rule and the enclave gate them. |
| The machine operator (runs the enclave) | No. The key is generated in-TEE and never exported; the enclave only obeys getTeeExtensionInstructionsSender(65645) = KeylessAccounts. |
| Someone who swaps the enclave image | No. A machine only joins ext 65645 by attesting to the registered code hash under the registered governance. A different image ≠ the registered hash. |
| A quantum attacker | Out of scope. They'd derive the key from its public key and sign on XRPL directly, bypassing Flare. Keyless enforces policy, not post-quantum key secrecy. |
The row that matters: an unlocked account is only as safe as the control key, because that key can
setRule. Locking is the one-way step that closes it — which is why the app pushes you toward it and why
3 accounts on Coston2 are already locked forever.
Nothing here needs to be taken on trust. Every claim above resolves to a call anyone can make.
RPC=https://coston2-api.flare.network/ext/C/rpc
KA=0x57eb332D7000752ee82a35cc1A75941F0a619979
DIAMOND=0x1a9C4A0f9D76c0b1D91d22E24E573a9b377618aE
# 1. This contract really is the extension's sole commander.
cast call $KA "extensionId()(uint256)" --rpc-url $RPC # 65645
cast call $KA "isBound()(bool)" --rpc-url $RPC # true
cast call $DIAMOND "getTeeExtensionInstructionsSender(uint256)(address)" 65645 --rpc-url $RPC # == $KA
# 2. The XRPL address really was reported by the enclave, not written by us.
cast call $KA "xrplAddressOf(bytes32)(string)" <walletId> --rpc-url $RPC
# 3. The rule really does refuse. Read-only, no gas, nothing moves —
# spoof the caller as KeylessAccounts to pass the rule's onlyAccounts gate.
cast call 0x2E5e2A1055670b2bc2baBd64f15825e69512d7e4 \
"authorize(bytes32,string,uint256,bytes32)" \
<walletId> "rSomeAddressNotOnTheList" 1000000 0x00 \
--from $KA --rpc-url $RPC # reverts: "recipient not allowed"The same refusal is one click in the app — every account page has a Try to break it panel that runs exactly this call against the real deployed rule.
The traction numbers in this README come from replaying KeylessAccounts events (WalletCreated,
PaymentAuthorized, RuleSet, RuleLocked, XrplAddressReported) from block 0 via the Coston2 explorer
API — no off-chain database is involved, so anyone can recount them.
The enclave is in this repo. enclave/go/internal/extension is the security core: processInit
generates the key from enclave entropy and there is no code path that imports or exports a private key.
The build is reproducible and the image's code hash is what a machine must attest to before it can join
extension 65645 — see enclave/REPRODUCIBILITY.md.
keyless/
├── backend/ Foundry — the policy engine
│ ├── src/
│ │ ├── KeylessAccounts.sol Keyring manager; the extension's sole instructionsSender
│ │ ├── KeylessStateVerifier.sol Attested state → xrplAddressOf (skeleton — see Status)
│ │ ├── rules/
│ │ │ ├── KeylessRuleBase.sol Shared scaffolding (onlyAccounts, lockable)
│ │ │ ├── ExchangeRule.sol Approved recipients + destination tags + per-tx cap
│ │ │ ├── RateLimitRule.sol Approved recipients + rolling / calendar / one-off budgets
│ │ │ ├── ScheduledRule.sol Payee + amount + calendar slot, capped runs
│ │ │ ├── ConditionalRule.sol Pays only on an FDC-attested fact (pins the whole request)
│ │ │ ├── FxrpRule.sol Mint → vault → redeem → approved cash-out
│ │ │ └── … AllowlistRule, SubscriptionRule, Fxrp{Mint,Defi} (superseded)
│ │ └── lib/, interfaces/
│ ├── test/ 78 tests, incl. the stolen-control-key adversary case
│ └── script/ Deploy + demo setup for Coston2
├── enclave/ The Flare Confidential Compute extension
│ ├── go/ The TEE node — generates keys in-enclave, signs XRPL
│ ├── go/tools/ register-extension, set-governance, register-tee…
│ ├── proxy/ Self-contained tee-proxy build
│ └── deploy/executor/ Watchers: FXRP mints, conditional releases, scheduled runs
├── frontend/ Next.js + viem — the wallet UI (embedded control key)
└── *.md FCC_TRACK2 · SECURITY_NOTES · USER_FLOWS · DEPLOY_RUNBOOK
Deeper docs: FCC_TRACK2.md (Confidential Compute deep-dive) ·
SECURITY_NOTES.md (findings and the invariants they taught) ·
USER_FLOWS.md · DEPLOY_RUNBOOK.md
# 1. Contracts
cd backend
forge install && forge build
forge test # 78 tests, incl. the stolen-control-key case
# 2. Enclave + registration (simulated TEE, Docker + ngrok)
cd ../enclave
bash ./scripts/use-chain.sh local coston2 go
ngrok http 6674 # paste the URL into EXT_PROXY_URL
bash ./scripts/start-services.sh --chain coston2
bash ./scripts/post-build.sh # allow-tee-version → set-governance → register-tee
# 3. Frontend
cd ../frontend && npm install && npm run devSee DEPLOY_RUNBOOK.md for the full go-live sequence and its gotchas.
26 distinct owners have created accounts on Coston2, and several of them fed back while it was being built — one in unusual depth, working through every policy end to end and writing up what broke. Their feedback did more than polish the UI; twice it changed how the product handles keys.
| What they hit | What changed |
|---|---|
| Couldn't see their accounts after switching browser | Accounts are now recovered from chain (WalletCreated events), not from local storage — so the account list never depends on the device it was made on |
| A 64-character hex key was "difficult to write down — get this wrong & your assets are gone" | The control key became a 12-word phrase, with a backup screen that blocks account creation until it's confirmed written down, and no copy button |
| Ended up with accounts and no backup, because backup was offered beside account creation rather than required | The backup gate now comes first — the option existed, it just never insisted |
| Several addresses across policies "without recognisable names make it very confusing which wallet you're sending XRP to" | Click-to-name on every address, in every policy, shared across accounts under one control key |
| Scheduled a payment "for today", nothing had run by 07:50 UTC, and couldn't tell a wait from a failure | Every time is shown in the reader's own timezone, named — calendar boundaries are 00:00 UTC, and a date alone can't answer "is this late or is this waiting?" |
| "Not having to manually note the time would be helpful" — no way to know when a spending limit refills | The allowance now shows what's left and counts down to the refill |
| Read the approved-recipients list and the spending cap as one setting | Two labelled groups, so it's clear the allowance applies to every payee |
| Read the payout panel as a second "try to break it" | It's hidden until the rule says a payment is possible, and the account explains why it's absent |
The pattern worth naming: every localStorage convenience turned out to be a recovery question in disguise. That lesson came from a user, not from us, and it's why the account list moved on-chain while nicknames deliberately stayed local — losing a nickname costs you a label, losing your account list looked like losing money.
Feedback from the Flare dev team shaped the architecture too: the answer to "what happens when a TEE restarts?" is what made threshold key backup the top of the roadmap rather than a footnote.
A new capability is one new rule contract. The key, the enclave and the account never change — which is why this list is a consequence of the architecture rather than a wishlist.
- Conditional payments — payouts gated on a Data Connector-attested fact, with the whole request pinned
- The FXRP round trip — mint, vault, redeem home, cash out to approved payees
- Scheduled payments — payee, amount and calendar slot fixed; missed runs skipped rather than accrued
- Destination-tag pinning — a recipient bound to (address, tag), so the right exchange under the wrong tag is refused
- Threshold key backup (
walletkeymanager). The one thing between this and a wallet people should trust with real money: secret-share the signing key across ⅔ of Flare's data providers plus the owner's key admins, so a dead machine doesn't mean dead funds. The primary post-hackathon milestone. - Hardware attestation (
MODE=0). Move off the simulated enclave onto real Confidential Space, so the code hash is attested by hardware rather than fixed by configuration. - Finish
KeylessStateVerifier. Today a trusted relayer key writesxrplAddressOf. Replacing it with a contract that accepts the address only if the TEE attested to it takes three things: Flare'sITeeExtensionStateVerifierinterface, an enclave that publisheswalletId → r-addressthrough tee-node's attested state channel rather than its own HTTP endpoint, and the verifier itself. The enclave change is why this waits on (1) — a restart today loses every key.
- Caller-aware rules.
authorize()doesn't currently see who calledpay(). Passing the caller through would let a rule say "only this agent may ask", which is what an agent paying unpredictable counterparties would need. - Cross-currency payouts. XRPL settles cross-currency payments natively (
SendMaxin XRP,Amountas an issued currency). Teaching the instruction and the enclave to build one would let a policy hold a rule like "convert to RLUSD and pay, and nothing else." - Yield without wrapping. Native XRPL AMM positions under policy, and an enclave-held Flare staking key that can stake but can never withdraw elsewhere. Keyless wouldn't source the yield — it would make an existing position undrainable, which is the same promise as the rest of the product.
- DAO-controlled XRP. A DAO on Flare votes; native XRP moves on XRPL. No bridge, no wrapped asset, no multisig ceremony.
- Privacy. The enclave buys key custody and code integrity. The rules are public on purpose — an unreadable policy is not a safety guarantee.
- Post-quantum key secrecy. Keyless enforces policy. Anyone who could derive the key from its public key would sign on XRPL directly and never touch Flare.
- Simulated TEE mode (
MODE=1) today — a fixed code hash, not hardware attestation. The architecture is attestation-ready; we say this plainly rather than imply guarantees we don't yet have. KeylessStateVerifieris a skeleton, andxrplAddressOfis written by a trusted relayer key. That key is the last trusted role in the system. It cannot move funds, change a rule, or make the enclave sign anything — but it names your deposit address, andFxrpRulederives your mint target from it. The verifier is what removes it.- No private inputs. The enclave buys key custody and code integrity, not confidentiality — your rules are public on purpose, so anyone can check them.
- Keyless is not "a smart account for XRP." That's access and UX, and Flare Smart Accounts already do it well. Keyless is about safety: a TEE-held key that even your own key can't drain.
- Not post-quantum. Keyless enforces policy, not key secrecy against a quantum adversary — anyone who could derive the key from the public key would sign on XRPL directly, bypassing Flare entirely.
Today's deployment runs one simulated enclave with keys in RAM — a demo shortcut, not the production
design. Its honest limitation: if that machine restarts it regenerates its identity and loses its
in-memory keys, so wallets created before the restart stop working (on testnet, recover routing with
enclave/scripts/reregister-railway.sh). On mainnet that would be
unacceptable for a wallet — and it's exactly what the production architecture removes:
- Threshold key backup (
walletkeymanager). The signing key is secret-shared —sk = S_provider + S_admin— across ⅔ of Flare's data providers plus the wallet owner's own key admins, and re-sharded periodically. If a machine dies the key is restored onto another attested TEE. Machine death ≠ fund loss, and no single party ever holds the key. - Hardware attestation (
MODE=0). Real Confidential Space ties identity to attested hardware, so it's stable across restarts and there are no orphaned registrations. - A fleet of machines. Many attested TEEs per extension, so one restarting doesn't take the service down.
Note the distinction this turns on: there is no supported way to restore an old teeId, and Keyless doesn't
want one. Identity is disposable; the key is what must survive — and Flare's own threshold backup is how.
Mainnet Keyless is then as trustworthy as Flare's security root: a key that cannot be lost by any single party and cannot be extracted by any single party — only reconstructed by a decentralised threshold, and only inside policy-enforcing code.