Appearance
Custody
The honeypot this exists to avoid
A gift card number is a bearer instrument: whoever holds it can spend it. There is no account to lock and no chargeback.
So a database of third-party card numbers, encrypted under a key only the operator holds, is a honeypot. One environment compromise yields every seller's card, each spendable at once, with no recourse for sellers.
The operator would lose reputation, the sellers their money.
The vault hides the card number. Hiding the listing itself, so nobody can race a valuable card, is a separate design: see Hidden intents.
The rule
No multi-user supply path may seal a real seller secret under a custodial backend.
The rule lives in code, as a function every supply path must call, because a written policy is read once and then forgotten. A path that skips it has to call some other function, and a search of the code finds that. A forgotten check in a document would leave nothing to find.
The operator carve-out, and why it is not a loophole
Sealing the operator's own card under the operator's key breaks no trust: they already had the card.
The rule bites the moment someone else's card is involved. The only distinction is ownership. Convenience plays no part.
Fail closed, by classification
| Class | Meaning | May hold a seller's card? |
|---|---|---|
custodial | One party's key alone decrypts | No |
non_custodial | No single party can decrypt | Yes |
test_only | A test double | Only outside production |
unknown | Not classified | No (refused) |
A future backend must be deliberately declared non-custodial to carry third-party cards. Forgetting to classify one stops seller onboarding: the safe direction to fail.
How non-custody is meant to work
Each card is encrypted to a key that exists only inside Lit's secure enclave, and only for one exact piece of code. Lit derives the key from the code's fingerprint, so changing one byte of the code gives a different key. Nobody holds that key: not us, and not our Lit account key.
Lit can still reach it. Lit's release multisig (2 of 4 signers, all Lit's, no timelock) decides which code the enclave hands keys to. So on Lit's service, Lit alone could open any card, whatever our code checks. Running our own copy of the service takes Lit off that list, but puts whoever approves our copy's releases on it. If that is us alone, we could approve code that reads the keys, and open every card. So "nobody can open it alone" also needs release approval we do not hold alone: signers we do not control, and a review delay (a timelock) before a release runs.
Anyone can run the vault's code, so what it checks before it opens a card is the whole defence. A card is non-custodial only if that check reads a fact our server cannot fake.
Today the check is "our server signed this". So our server can still open any card it sealed this way, alone. The guard classes the Lit vault as custodial and refuses it for other people's cards. The fact it needs is an escrow deposit on chain. That contract (SalvoEscrowV1) and a rule that reads it are written, and since 2026-10-10 they run on Tempo's test network and on Lit. They are not reviewed, so the guard does not count them yet. The next section is that design.
A seal binds to the listing, because no order exists yet when a card is listed. The order is checked when the card is opened.
The escrow vault
The escrow vault is the design for other people's cards. Its rule:
A card opens only for a purchase that has already paid that card's seller. The payment is read from the chain, never from us.
Lit runs one frozen piece of code, the escrow template. It reads the purchase itself, from two chain hosts that must agree, at a block both call final. It trusts nothing our server says.
- SellerTypes the card into a page that seals it in their own browser. Our server gets ciphertext only
- TicketBefore any money moves, Lit checks the listing is for sale and signs a ticket for one buyer and one phone key
- ShopperOne transaction pays the escrow and records the card as opened
- ReleaseOnce that is final, Lit sends the card encrypted to the key the shopper's own transaction named
- SellerPaid by the escrow 24 hours later. Anyone can trigger it
The ticket comes first, so a refused card costs the shopper nothing. Paying and opening are one transaction, so there is never a deposit waiting for a card.
Opened means sold. A gift card number that has been shown cannot be taken back. So the moment the card opens is the moment of sale. That is why nothing goes back to the shopper on a timer, and why only the seller can refund. An automatic refund after a card was shown would hand out a free card: keep the number, get the money back, spend the card next week.
What our server can do. It relays the ticket and the release, so it can block them. It cannot open a card: the release goes to the shopper's phone key, and the shopper's phone decrypts it. It cannot move money: the escrow has no owner and no admin, and pays only a purchase's seller, or its buyer when the seller refunds. In the design, someone holding our servers, database and every key still cannot open a listed card without buying it, except through the page itself, below.
The page does not take its trust from our server. The escrow's address, the card service's signer and sealing key, and the escrow's code hash are built into the page's own code. A config from our server that names any other is refused. So a server that changed only its answers could make a page refuse to work. It could not get a card sealed to its own key, or a shopper's allowance signed over to another contract.
Who can still open one. Lit, through the same release multisig as above. And any script our site serves, while it runs. A seller types the card into our page. And every card a phone has bought stays on that phone, sealed under a key the browser lets any script on our site use, at any time, without asking the person. So a script we serve, or one someone else gets onto any page of our site, can open every card on every phone that visits while it runs, leftovers and cards bought weeks ago included, not just the one on screen.
Status
Built and tested offline. The contract, the template, the runbook scripts, the routes, the keeper, and the screens: the till's dollars rail, Account, and Salvo Pay's /sell. On testnet since 2026-10-10, and the second escrow (SalvoEscrowV2) since 2026-10-13, which takes every new sale. SalvoEscrowV1 is deployed on Tempo Moderato at 0x75c9aabe8254559f865e14d6e5fa70c738869bef. The template is pinned to it, and the live smoke passed every case on Lit. Test dollars only. Not reviewed. Off unless ESCROW_RAIL=testnet, and production does not set it.
Nothing claims non-custodial yet. No backend is declared non-custodial until a second reviewer signs off. The testnet run is done; the review is not. On the testnet our server takes an escrow listing as the operator's own card: from anyone, unless ESCROW_SELLER_ALLOWLIST names who may. Anyone with test dollars can buy it, so list only your own card.
The gap this had for most of the project
A Lit vault backend, then called non-custodial, was written in Phase 7. It never worked: it called endpoints that do not exist. It was also referenced only in comments. Nothing selected between backends: every call site constructed the custodial vault directly.
So the guard correctly refused every third-party card, and multi-user supply was structurally impossible on every product line. Provisioning the Lit API key, the runbook's biggest-payoff item, unblocked nothing: no code path would have used it.
A vault resolver now selects the backend by owner. It only selects. The guard still refuses, unchanged, so a resolver bug produces a refusal, never a custody violation.
The resolver also fails closed in the other direction. No backend is classified non-custodial today, so a seller's card is refused instead of downgraded to custodial. That fallback would silently turn a missing piece into the honeypot while looking like "making onboarding work".
Status
Rebuilt for Lit's current service. Its live smoke passed on Lit on 2026-10-10, with a fake card. Custodial: the unlock rule is our own signature. No production path uses it. Every card is recorded as the operator's and sealed in the ordinary vault, including any card a stranger types into the public sell screen (an open decision).
What can still be opened, and by whom
| What | Who can open it | Why |
|---|---|---|
| Operator cards | Us, with VAULT_MASTER_KEY | By design: they are ours |
| Cards sealed to the Lit vault | Us, with the attestor key | The unlock rule is our own signature |
| A stranger's card typed into the sell screen | Us, with VAULT_MASTER_KEY | It is recorded as the operator's own. Closing that is an open decision |
| Any card at the till | Our server | It sees the number at open, balance check, barcode and settle |
| Every Lit key | Lit's release multisig (2 of 4, no timelock), plus Phala and Intel TDX underneath | They run the enclave the keys live in |
| Escrow cards (as designed, testnet only, not reviewed) | Nobody, without paying the card's seller on chain. Lit's stack, as above | The template opens a card only for a final purchase, and only to the buyer's phone key |
| An escrow card being typed, and every card kept on a phone | Any script our site serves, while it runs | The seller types into our page. Every card a phone bought is kept on it under a key any script on our site can use, at any time, with no prompt. So is every card a shopper adds as store credit on /account |
A remainder the till lists again keeps its row and its seal. Nothing is unsealed to list it: the seal names only the listing, and the next sale opens it the way the first did.
Relocating plaintext entirely
Applying a card at a merchant breaks into four steps, and exactly one touches plaintext. If that step runs inside the TEE, card plaintext never exists on our infrastructure.
Whether it can depends on session portability: does the merchant accept a checkout session replayed from a different egress? That has never been measured. The selector and the local path are built. The in-enclave apply step is not. The old Lit applier was removed: a separate Lit Action has its own key, so it cannot open a card sealed to the vault. If it returns, it is an apply step inside a vault template, checked like the open. The selector refuses to fall back to the local path: a loud failure is recoverable, a silent custody downgrade is not.