Claim fees with an X account
Status. Built and tested against a mock identity provider on a local validator. It has never been run against the real X API, because no X developer credentials exist yet. It needs credentials, a claim signing key and an admin step before it can run for real. It has not been audited, and nothing here is deployed.
What it is
A creator can name an X account when they launch, instead of a wallet. The vault then pays the creator share of every fee collection into an escrow: a program-owned account that holds the fees. Later, whoever controls that X account signs in on the claim page and sends the escrow's balance to any wallet they choose. The launch itself stores the numeric X user id, not the handle, because handles can be renamed, sold or recycled.
Two things follow from this choice. The creator wallet cannot redirect the share (the vault refuses set_recipient on these launches), and the creator does not need a wallet at launch time to be paid, only an X account they still control when they claim.
How a claim works
- The claimant opens
/claim, connects the wallet that should receive the fees, and signs in with X (OAuth 2.0 with PKCE). The server reads the numeric id of the account that signed in and keeps it in a signed, httpOnly cookie that lasts one hour. - The page lists launches whose
creator_x_idequals that id, by reading Launch accounts over RPC, and shows what the escrow holds. Anyone can register launches that name any X id, so the page checks at most 400 of them, shows at most 50 (launches with fees waiting first, then by address) and says "Showing 50 of N" when it cuts the list. A box lets you paste a launch address to find one directly. - On Claim, the page asks
POST /api/claim/permit. The service checks that the launch account belongs to the configured vault program and has the right size, re-derives the escrow address from the launch (it never uses an address from the browser), checks that the session's X id equals the launch'screator_x_id, reads the escrow's token balances and its claim nonce, and signs a permit. All of these reads usefinalizedcommitment, so a permit is never based on state that could still be rolled back. - The browser builds a transaction with two instructions: the native Ed25519 program instruction that carries the permit signature, then
claim_creator_fees. The vault reads the instructions sysvar, checks the signature against theclaim_signerkey stored in its config, checks the nonce and expiry, pays out, and increments the nonce.
The permit is an ed25519 signature over exactly these bytes, all numbers little endian:
"latch-claim-v1" 14 bytes
program id 32
launch 32
claimant wallet 32
nonce u64 must equal the escrow's claim_nonce
expiry i64 unix seconds. The Latch service signs at most 5 minutes ahead. The program rejects expiries more than 15 minutes ahead and ones that have passed
max amount, token A u64
max amount, token B u64
Because it names the claimant wallet, a permit seen on the network cannot be reused for another wallet. Because the nonce goes up after each claim, a permit works once. Because the maximums are exactly the escrow balances at signing time, a permit cannot take more than was there.
What you have to trust
| Who | What they can do | If they fail |
|---|---|---|
| The claim signing key, and the vault admin who can replace it | Together they control every X-account escrow. The key can sign a permit for any escrow with any claimant. The admin can swap the claim signer for a key they hold with set_claim_signer, and that takes effect at once with no delay. |
If the key leaks, or the admin is malicious or compromised, an attacker can take everything waiting in every X-account escrow. If the service is offline, claims stop. There is no on-chain cap on what a permit can release, apart from the escrow balance, and no delay that would let creators react. |
| The RPC node the service reads from | The service believes it about which X id owns a launch. The program never checks the X id itself. | A lying RPC (malicious, hacked or intercepted) can make the service sign a permit for the wrong escrow. RPC_URL must be a node you run or fully trust. |
| X | Says which account signed in. | If X returns the wrong id, or the app credentials leak, someone could claim as another account. |
| The launch creator | Typed the handle at launch. creator_x_id is the creator's own unverified claim: nothing checked that they control the account or that it is the one they meant. |
A typo is irreversible. A wrong id, or a lookalike account, strands that share or sends it to whoever owns that other account. There is no way to change the id or take the share back. |
The program rejects expiries more than 15 minutes ahead. That limits how long a stolen permit lives, and it does not limit who can get one if the key is stolen.
The chain cannot verify an X login. Any claim-by-social-account system rests on a signer like this one, and the claim page says so. Ways to reduce the risk later: several signers that must agree, a delay before large claims, key rotation on a timelock, a registration-time proof that the creator controls the account, and a fallback for funds nobody claims. None of these exist today.
Setup for real use
- Make the claim signing key. Run
npx tsx scripts/gen-claim-signer.mts. It prints a public key and a secret and writes nothing to disk. Put the secret inCLAIM_SIGNER_SECRETon the server only, using your host's secret store. Never commit it or paste it into a chat. - Register the public key. The vault admin sends
set_claim_signerwith the public key. Until then every claim is refused. The service also refuses to sign if the key on chain is not its own. - Create the X app at the X developer portal. Choose OAuth 2.0, a web app (confidential client), read permission, and the scopes
users.read tweet.read. Set the callback URL to the exact value ofX_REDIRECT_URI, for examplehttps://your-site/api/claim/callback. Copy the client id, client secret and a bearer token intoX_CLIENT_ID,X_CLIENT_SECRETandX_BEARER_TOKEN. - Set the rest of the environment:
SESSION_SECRET(at least 32 random characters),RPC_URL,NEXT_PUBLIC_RPC_URL,NEXT_PUBLIC_VAULT_PROGRAM_ID.RPC_URLmust be a node you control or fully trust (see above). Every variable is described in.env.example. - Behind a proxy? Set
TRUST_PROXY=1only when a proxy in front overwritesx-real-ipandx-forwarded-for. Without it those headers are ignored and every route shares one rate-limit budget across all callers, so a spoofed header cannot buy a fresh one (limits tied to a signed-in X account apply either way). Run more than one server? The rate limiter is in memory per process. With several instances, put a shared store behind it first, or the limits multiply by the number of instances. - Do not set
X_MOCKon a public server. The mock lets anyone sign in as any handle. It fails closed: it only runs whenNODE_ENVisdevelopmentortest. In production, withNODE_ENVunset or with any other value, the server logs a refusal and sign-in answers 503.X_MOCK_UNSAFE_OK=1overrides that and exists only for private demos.
The service interface
| Route | Purpose |
|---|---|
GET or POST /api/x/resolve |
Turns a typed handle into the numeric id and current handle. The launch page uses this. |
POST /api/claim/start |
Starts the X login, sets a signed state cookie for ten minutes, returns the address to send the browser to. |
GET /api/claim/callback |
Where X sends the browser back. Completes the login, stores the verified id in the session cookie, redirects to /claim. |
GET or DELETE /api/claim/session |
Who is signed in, or sign out. |
POST /api/claim/permit |
Takes { launch, claimant } and returns the permit fields and signature. Refuses when the session's id does not match, when the escrow is empty, or when the vault has no matching claim signer. |
Errors are JSON, { "error": "..." }, in plain language, with the usual status codes (400 bad input, 401 not signed in, 403 wrong X account, 404 unknown launch, 409 nothing to claim, 413 body too large, 429 too many requests, 503 not set up, 504 X too slow). Request bodies are size-checked before they are read. Every call to X has an 8 second time limit and a cap on the size of the answer.
Failure modes
- Signing service offline or key lost. Nobody can claim. The escrow keeps the funds. The admin can set a new signer, and claims resume with permits from the new key.
- Signing key stolen, or the admin is malicious. The signing key and the admin who can replace it control every X-account escrow. Rotate with
set_claim_signerstraight away. Anything already claimed is gone. There is no cap and no delay on-chain beyond the escrow balance and the 15 minute expiry limit: until the signer changes, whoever holds a key the vault trusts can take every unclaimed X-account share. Rotation assumes someone notices first. - Wrong or lying RPC. If
RPC_URLpoints at a node that lies, the service can sign a permit for the wrong escrow. Use a node you control. - Wrong X id at launch.
creator_x_idis the creator's own unverified claim and a typo is irreversible. If the id belongs to nobody who will sign in, that share stays in escrow for good. If it belongs to a lookalike account, that account's owner can claim it. Nothing can undo this. - Wallet adds steps between the checks. The vault requires the Ed25519 signature check to sit immediately before the claim instruction. A wallet that inserts its own instructions between them (some add safety or simulation steps) can break that. The symptom is a claim that fails with a "permit missing" style error, nothing moves, and pressing Claim again with the same wallet fails the same way. Trying another wallet is the workaround. This has not been tested with real wallets.
- Page flooded with fake launches. Anyone can create launches naming your X id. The page caps and batches its reads, shows at most 50, and lets you paste a launch address. Fake launches cannot take your fees, but they can bury your real ones in the list.
- X API down, slow or credentials wrong. Sign-in fails with a message, or times out after 8 seconds. Nothing is lost.
- The X account is lost or suspended. Whoever cannot sign in cannot claim. There is no recovery path in this design.
- Permit expires before the transaction lands. The vault rejects it with a clear error. Press Claim again for a new one.
- Someone else claims first. Only the owner of the X account gets a permit, so this means the account itself was used, or the signing key was. Check where the fees went.
- A handle is renamed. No effect. The launch is tied to the numeric id.
What was tested
- Unit tests for the permit bytes (pinned to vectors computed by hand and checked with a second ed25519 implementation), the mock and real X request shapes, session cookies, input validation, rate limiting (including spoofed address headers), the fail-closed mock for every
NODE_ENVvalue, time limits and size caps on calls to X (using a fetch that hangs), body size limits, batching of account reads, and the launch list cap. - An end-to-end run on a throwaway local validator with the real vault binary: a handle launch, trading fees cranked into escrow, a claim by the mock-verified account, and refusals for a replayed permit, an expired permit, a permit for another wallet and a wrong X account.
Not tested: the real X API, a deployed service, several server instances, and any hostile review. See the security model for what else is open.