A keyless, policy-controlled XRPL account, built on Flare Confidential Compute.
Submission for the Flare Summer Signal hackathon — Confidential Compute Apps track.
Judges: the deployed app has a /verify page listing every claim with the command to
check it. The same material is in The two halves, and which one runs below.
One click, nothing to configure. The live Coston2 address is compiled in as a default, so there are no environment variables to set.
If you imported the repo by hand, set Root Directory to
frontendon the import screen (or Settings → General on an existing project). Leaving it at./makes Vercel install the rootpackage.json, which has no dependencies, and the build then fails withnext: command not found. The button above sets it for you, and a rootvercel.jsoncovers the case where it is left at./.
An XRPL account is only as safe as the secret behind it. Lose the seed and the funds are gone; leak it and they are someone else's. Every mitigation people reach for — hardware wallets, multisig, custodians — works by making the key harder to use, which is also what makes it harder to use legitimately.
PolicyGuard removes the key from the picture instead.
The XRPL signing key is generated inside a Trusted Execution Environment and never leaves it. There is no seed to back up, no secret to export, and no API that returns one. What the account holder controls is not a key but a policy, published on Flare: at most X XRP per rolling 24 hours. The enclave holds the only copy of the key, so the enclave is the only thing that can enforce that policy — and it does, on every payment, before it will produce a signature.
Every wallet, policy change, and payment decision in this product is a transaction on Flare that dispatches an instruction to a TEE. There is no local path, no simulated verdict, and no offline fallback. Open the app without a deployed contract and it tells you so and stops — there is deliberately nothing to click.
Concretely, one action is:
1. wallet signs requestPayment(...) → a real Flare transaction
2. InstructionSender calls TeeExtensionRegistry
3. the registry emits TeeInstructionsSent → instruction id + TEE machines
4. data providers pick it up, reach consensus, co-sign
5. the TEE proxy hands it to the TEE node
6. the node calls the extension: POST /action
7. the enclave re-derives the 24h spend and decides
8. the signed result is published back through the proxy
9. the app polls the proxy and ABI-decodes the verdict
Steps 3 and 9 are in frontend/src/lib/fcc.ts. Note step 3:
the proxy URL is read out of the on-chain event, not from configuration — the chain
decides which TEE machines handle an instruction, so a proxy that was never assigned
the work cannot answer for it.
Being precise about this matters more than claiming everything works.
| Component | Status |
|---|---|
| XRPL key generation, address derivation, canonical serialisation, signing | Real. Verified against rippled's own test vectors. |
| Daily-limit policy engine | Real. A rolling 24h ledger, re-derived in-enclave on every request. |
InstructionSender.sol |
Real. Official sendInstructions / OPType / OPCommand pattern. |
| Instruction dispatch and result collection | Real. On-chain event → data-provider consensus → proxy. |
| Frontend | Real. Sends transactions, decodes signed results. No simulated branch exists. |
| Hardware attestation | Simulated on Coston2 (SIMULATED_TEE=true, MODE=1). This is Flare's own local setup, not a substitute implementation — the extension binary and instruction path are identical, only the attestation quote is a test quote. Production requires a GCP Confidential Space VM. |
| Network | Status | Why |
|---|---|---|
| Coston2 | Live. Contract deployed. | The only network with a public FCC contract set — FlareTeeManager at 0x1a9C…18aE. |
| Songbird | Prepared, not live. .env.songbird.example |
The FCC rollout was approved by governance, but the contract set is not published in the official address book. There is no FlareTeeManager address to point at. |
| Flare mainnet | Prepared, not live. .env.flare.example |
Flare's own documentation describes FCC as "in the final stages of development and not yet a fully public production system." No public mainnet contract set exists. |
| InstructionSender | 0x494bd161F29F4024d91408AC1d480EF960f91348 |
| Source | Verified — read it |
| Extension | Registered, id 65955 |
isTeeAvailable() |
false — no enclave registered |
PolicyGuard has a policy layer on Flare and confidential execution in an enclave. They are deliberately separable, and the contract reports which one actually ran.
| Status | Evidence | |
|---|---|---|
| Live on-chain — wallet records, daily limits, payment requests, policy reads | Working | Every call below succeeds on Coston2 |
| FCC TEE execution — key generation, policy evaluation, XRPL signing | Blocked | isTeeAvailable() is false; no machine registered |
_send checks machine availability before dispatching. With none registered it emits
TeeUnavailable(opType, opCommand) and returns false, so the on-chain record is written
and the caller is told the enclave was never asked:
RPC=https://coston2-api.flare.network/ext/C/rpc
C=0x494bd161F29F4024d91408AC1d480EF960f91348
cast call --rpc-url $RPC $C 'extensionId()(uint256)' # 65955
cast call --rpc-url $RPC $C 'isTeeAvailable()(bool)' # false
cast call --rpc-url $RPC $C 'getWallet(uint64)(address,uint64,uint64)' 1
# 0x764377752663Fa8AF23226C87dCBCDC270BB7432
# 10000000 <- a 10 XRP daily limit, published on-chainNothing simulates the missing half. There is no code path that produces a signed XRPL payment without an enclave — the UI reports "recorded on Flare, enclave not invoked" and links to the transaction.
Registering a TEE machine needs Flare's extension proxy, which connects to a Coston2
indexer database as the first statement of proxy.Run. With placeholder credentials the
stack builds and starts, and the proxy exits immediately:
$ docker compose ps -a
flarenetwork-extension-tee-1 Up
flarenetwork-redis-1 Up
flarenetwork-ext-proxy-1 Exited (2)
$ docker logs flarenetwork-ext-proxy-1
panic: connecting to database: opening mysql connection to
<indexer-db-host>:3306/<indexer-db-name> as <indexer-db-user>:
dial tcp: lookup <indexer-db-host>: no such host
tee-proxy/internal/proxy/proxy.go:49
There is no offline or degraded mode, so the credentials are a hard prerequisite.
The scaffold's .env sets CHAIN=coston2 for the Docker stack, and Foundry reads the
same variable as --chain, where coston2 is not a valid value. If cast fails with
invalid value 'coston2' for '--chain', move .env aside for that command:
mv .env .env.bak && cast call ... ; mv .env.bak .envThe scripts in scripts/ are unaffected — they only shell out to Foundry for the
deploy, which sets the chain explicitly.
One file, one command:
cp config/proxy/extension_proxy.coston2.docker.toml.example config/proxy/extension_proxy.coston2.docker.toml
# fill in the [db] block, then:
ngrok http 6674
EXT_PROXY_URL=https://<your-tunnel> ./scripts/enable-tee.shenable-tee.sh refuses to run against placeholders, starts the
stack, registers the machine, and confirms isTeeAvailable() flipped to true. No code
change and no redeploy — the app reads that flag from the chain on load.
The Songbird and Flare env files have empty address and proxy fields. That is deliberate: inventing plausible-looking addresses would produce something that appears deployable and is not, which is worse than an obvious gap.
Two things must be true before mainnet is appropriate, beyond the addresses existing:
SIMULATED_TEE=false on real Confidential Space hardware, and key persistence through
Flare's WalletKeyManager facet — see Known limitations.
┌──────────────────────────────────────────────────────────────────────┐
│ Flare (Coston2) │
│ │
│ InstructionSender.sol ──── the public policy record │
│ · createWallet() · setDailyLimit() · requestPayment() │
│ · stores the limit, emits every request as an event │
│ · deliberately does NOT reject over-limit requests │
│ │ sendInstructions() │
│ ▼ │
│ TeeExtensionRegistry ── emits TeeInstructionsSent │
└────────────────────────────┼─────────────────────────────────────────┘
│ data providers reach consensus, co-sign
▼
TEE proxy ──► TEE node
│ POST /action
┌─────────────────────────────────────────┼────────────────────────────┐
│ TEE extension (the enclave) ▼ │
│ WALLET/CREATE → generate a secp256k1 keypair, return the address │
│ POLICY/SET_LIMIT → store the rolling 24h limit │
│ PAYMENT/REQUEST → evaluate, then sign or refuse │
│ │
│ · the signing key lives only here, with no export path │
│ · the 24h spend ledger lives only here │
└──────────────────────────────────────────────────────────────────────┘
It would be natural to revert on an over-limit request. PolicyGuard does not, for two
reasons.
The first is that a reverted transaction never reaches the enclave, so the user gets a failed transaction instead of an explained decision. The refusal, with its reason, is the product.
The second is honesty about where trust sits. The enclave holds the key, so it is the enforcement point whether or not the contract also checks. A second, weaker check on-chain would imply a guarantee the contract cannot make.
What the contract does provide is auditability — the limit and every request are
public — and a cap. The current on-chain limit travels with each instruction, and
the enclave applies whichever of the two is stricter
(policy.go). A stale enclave can never authorise more
than the published policy allows, and a forged instruction claiming a higher limit
cannot raise the enclave's ceiling.
| Concern | How it is handled |
|---|---|
| Replayed instructions | Verdicts are memoised by request id, so a duplicate returns the original answer instead of spending the budget twice. |
| A relayer choosing the evaluation time | The rolling window uses the enclave's clock, never the instruction's timestamp. |
| A typo'd destination eating the allowance | The destination is checksum-validated before the policy is consulted; a bad address consumes nothing. |
A duplicate CREATE stranding funds |
Wallet creation is idempotent — a redelivered instruction returns the existing address. |
| A wallet with no policy yet | The limit starts at zero, which means deny everything. |
Key material leaking via GET /state |
xrpl.Keypair has no accessor for the private key, not even unexported. A test asserts the state report against an allowlist. |
| SSRF via the result-fetching route | The proxy URL comes from an on-chain event but is still validated: http(s) only, and private/loopback targets are refused unless FCC_ALLOW_PRIVATE_PROXY=true. |
Prerequisites
- Go 1.25+, Node 20+, Foundry, Docker, and
ngrok - A funded Coston2 account — faucet
- Coston2 indexer database credentials — request these from Flare support. The extension proxy follows the chain through an indexer and cannot start without them. There is no way around this and no substitute path; it is the one hard external dependency.
Steps
# 1. Configure
cp .env.coston2.example .env.local.coston2
# Fill in DEPLOYMENT_PRIVATE_KEY and INITIAL_OWNER.
# 2. Expose the proxy, then select the chain
ngrok http 6674
# Put the HTTPS URL in EXT_PROXY_URL, then:
bash ./scripts/use-chain.sh local coston2 go
# 3. Deploy the contract and register the extension
npm run fcc:setup # scripts/pre-build.sh
# Writes config/extension.env with EXTENSION_ID and INSTRUCTION_SENDER.
# 4. Configure the indexer
cp config/proxy/extension_proxy.coston2.docker.toml.example \
config/proxy/extension_proxy.coston2.docker.toml
# Fill in the [db] block with the credentials from Flare.
# 5. Start the stack
npm run fcc:start # scripts/start-services.sh
# 6. Register the TEE machine
npm run fcc:register # scripts/post-build.sh
# 7. Prove it end to end, on-chain
npm run fcc:test # scripts/test.shscripts/test.sh runs the whole flow against the live chain and asserts each step,
including that an over-limit payment comes back refused rather than failed.
Then the UI:
cd frontend
cp .env.example .env.local
# NEXT_PUBLIC_INSTRUCTION_SENDER = INSTRUCTION_SENDER from config/extension.env
# FCC_ALLOW_PRIVATE_PROXY=true only if your proxy is on localhost
npm install && npm run devOnce the stack above is running, at http://localhost:3000/app:
-
Connect a wallet. The header shows the contract address and its extension id, both read from Coston2.
-
Create wallet. Signs a real
createWallet()transaction. The activity log links to the Flare explorer and shows the instruction id the registry emitted. The XRPL address that comes back was generated inside the enclave. -
Set the daily limit to 10 XRP. A second real transaction. The limit is then read back from the contract, not from the input box.
-
Request 4 XRP. Under the limit → the enclave returns a signed XRPL Payment. The
tx_bloband transaction ID are shown, and can be submitted to the XRPL testnet. -
Request 25 XRP. Over the limit → refused, with:
daily limit exceeded: requested 25 XRP, but only 6 XRP of the 10 XRP limit remains (4 XRP already spent in the last 24 hours)
No signature was produced. Not a rejected transaction — the signature was never created, because the key is inside the enclave and the enclave said no.
Evidence to check: every step in the activity log links to a Coston2 transaction,
and each shows the instruction id that the TeeInstructionsSent event carried. Those
ids are what the app polled the TEE proxy with. Nothing in the browser decided anything.
forge build # contracts
cd go && go test ./... # XRPL crypto, policy engine, handlers, ABI
npm run verify:abi # Solidity <-> Go <-> TypeScript agreement
cd frontend && npm run build
npm run fcc:test # on-chain end-to-end, needs the stack aboveWhere the assurance actually lives:
go/internal/xrpl— RIPEMD-160 against its published vectors, and address derivation against rippled'smasterpassphraseaccount (rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh). Canonical field ordering is checked by walking the serialised output; every signature is verified against its public key.go/internal/policy— accumulation, the exact boundary, partial window rolls, stricter-limit-wins, and that a refusal consumes no budget.go/internal/extension— the full flow through the real action envelope, plus replay, idempotency, and the state-report allowlist.npm run verify:abi— decodescast abi-encodevectors with the TypeScript decoders, and checks theTeeInstructionsSenttopic hash against Flare's official ABI. If that hash drifts the app silently ignores every dispatch, so it is pinned.
npm run install:frontend && npm run build && npm run startOr one click with the Vercel button above; vercel.json already accounts
for the app living in frontend/.
A hosted deployment still needs NEXT_PUBLIC_INSTRUCTION_SENDER set to a deployed
contract, and the TEE proxy must be publicly reachable — it is, if you are using ngrok,
since that URL is what gets published on-chain. Leave FCC_ALLOW_PRIVATE_PROXY unset
in production.
Stated plainly, because a judge will find them anyway.
- Attestation is simulated on Coston2.
SIMULATED_TEE=trueuses a test attestation quote. Real hardware attestation needs a GCP Confidential Space VM. - Keys do not survive a TEE restart. The FCC extension spec forbids extension
filesystem use, so wallets live in enclave memory. Production would use Flare's
WalletKeyManagerfacet for sealed backup. Restarting the TEE means new wallets. - One policy type. A rolling 24h spending limit, per the MVP scope. Destination allowlists, per-transaction caps, and time-of-day rules are not implemented — though they all fit the same instruction shape.
cosignersis left empty in_send. The field is wired and documented, so requiring a co-signing threshold is a small change, but the MVP does not use it.- Coston2 requires indexer credentials from Flare. Nothing runs without them.
- Songbird and mainnet are configuration only — see the network matrix.
Built on the Flare Foundation's fce-sign
extension scaffold. The infrastructure layer (go/pkg/server, go/tools, the proxy
configuration, and the deployment scripts) is the scaffold's, kept intact per its
create-extension specification. The developer-owned pieces — the contract, the
handlers, the policy engine, the XRPL implementation, and the frontend — are this
project's.
Reference: FCC overview · guides · private key extension