jerrymusaga/Keyless

An XRPL account whose key lives in a TEE that will only sign what an on-chain policy permits. The operator holds no key and can sign nothing the policy forbids.

★ 0Forks 0TypeScriptGitHub ↗Compare

README

Keyless

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 →


Live on Coston2 right now

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)

The problem

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.

The solution

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.


How it works — the trust chain

Every link is verifiable on-chain or in open source.

  1. The key is born in the enclave. createWallet sends an INIT instruction, 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 reference fce-sign does the opposite — it imports an operator-supplied key. We deliberately have no such code path.)
  2. 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."
  3. One contract is the only boss. KeylessAccounts is the extension's sole instructionsSender (isBound = true). The enclave acts on instructions from that contract and nothing else.
  4. 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.

The enclave has exactly two operations

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.

pay() is permissionless — on purpose

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.)

The payment reference

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.


Architecture

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
Loading

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.


The five policies

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.


The flows

Creating an account

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.

Making a payment

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.

A conditional payout

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.

The FXRP round trip

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.


Deployed on Coston2

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.


Threat model

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.


Verify it yourself

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.


Repository layout

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


Quickstart

# 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 dev

See DEPLOY_RUNBOOK.md for the full go-live sequence and its gotchas.


What the Flare community changed

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.


Roadmap

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.

Shipped during the hackathon

  • 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

Next — closing what's honestly open

  1. 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.
  2. 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.
  3. Finish KeylessStateVerifier. Today a trusted relayer key writes xrplAddressOf. Replacing it with a contract that accepts the address only if the TEE attested to it takes three things: Flare's ITeeExtensionStateVerifier interface, an enclave that publishes walletId → r-address through 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.

Then — things native XRP structurally cannot do

  • Caller-aware rules. authorize() doesn't currently see who called pay(). 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 (SendMax in XRP, Amount as 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.

Not on the roadmap, deliberately

  • 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.

Status and honesty

  • 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.
  • KeylessStateVerifier is a skeleton, and xrplAddressOf is 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, and FxrpRule derives 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.

Path to mainnet

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:

  1. 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.
  2. Hardware attestation (MODE=0). Real Confidential Space ties identity to attested hardware, so it's stable across restarts and there are no orphaned registrations.
  3. 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.

Contributors

jerrymusaga

Issues