Dial.operator
Theme
Text +1 806 607 1387

Self-audit: Solana

Every way the Solana side of text-my-crypto could lose money: what an attacker needs, the worst they can do, how much is at stake right now, and how we'd close it. Worst first.

$53in people's wallets
$12in live escrows (8)
$55.77in the treasury
$120.77at stake if every key leaked

Read from Solana devnet and the ledger, at most once a minute. This is testnet: every dollar here is test USDC. Reviewed 12 October 2026.

How text-my-crypto holds money

text-my-crypto is custodial today. The server keeps every person's Solana key, sealed with one encryption key, and signs their payments once they approve with a texted code or a passkey. It also holds the treasury key (test money for new people, and network fees) and the escrow attestor key.

The escrow program is the one part that doesn't need trust in the operator for refunds: after expiry, anyone can send an escrow back to its sender. Before expiry, the attestor alone decides who it pays.

So the biggest risks are keys, not code paths. The fixes that matter most move keys off the server.

Fix first

  1. Stop keeping codes. Blank out codes in the message log and the model's history. Small change, closes the high-severity path that needs only the webhook secret and a log.
  2. Sign the webhook. Verify the SMS provider's signature instead of a secret in the URL.
  3. Lock the program. Move the upgrade authority (GFFm…gs88) to a multisig with a timelock, then make the program immutable after an audit.
  4. Fix the escrow recipient at creation. A stolen attestor key can then only pay the right person.
  5. Move keys off the server. Passkey-controlled wallets for people, and a KMS with per-key policies for the treasury and attestor.

Every case, worst first

The Cloudflare account or the Worker's code is taken over


Severitycritical
Worst-case loss now$120.77

Every user wallet, every live escrow and the treasury


Needs. Control of the Cloudflare account, a deploy token, code running inside the Worker (a malicious package pulled into the build counts), or the deploy laptop, which keeps the production env file.

Worst case. Every key is a Worker secret and the database is D1 in the same account. The attacker decrypts every user wallet, signs a claim for every escrow, and empties the treasury.

Impact. Every user's money at once. Every phone number and message is exposed, and the attacker can text anyone as the operator.

Already in place

  • Keys are Worker secrets, and on the deploy laptop in a git-ignored env file. Never in the repo, and never in the database unsealed.
  • Deploys go through one gated script: typecheck, lint, 95% branch coverage on money paths, a staging smoke conversation.
  • Dependencies are pinned by the lockfile.

How to harden it

  1. Give people their own keys: a Solana smart wallet whose authority is the person's passkey, so the server can propose a payment but can't sign one.
  2. Sign everything else in a KMS or HSM outside Cloudflare, with a policy per key (the treasury signs only drips and fees, the attestor only claims for escrows in the ledger). A hacked Worker can then ask, not sign.
  3. Hardware-key 2FA on Cloudflare and GitHub, short-lived scoped deploy tokens, and deploys only from CI with build provenance. No production keys on laptops.
  4. Review every lockfile change, and run the build without package install scripts.

The wallet encryption key and a copy of the database


Severitycritical
Worst-case loss now$53

Every user wallet


Needs. WALLET_ENCRYPTION_KEY plus a read of D1: an export, a backup, or a leaked API token with database access.

Worst case. Every user's Solana key is sealed with that one key (AES-256-GCM). With both, every key opens and every user wallet is swept.

Impact. Every user at once. Their addresses are burned and have to be replaced.

Already in place

  • AES-256-GCM with a fresh random IV for every secret.
  • The key exists only as a Worker secret.

How to harden it

  1. Envelope encryption: a data key per wallet, wrapped by a KMS key that only the signing service can unwrap, so a database copy plus one variable isn't enough.
  2. Bind each sealed key to its row (user and chain as authenticated data), so sealed keys can't be swapped between rows.
  3. Best of all, the passkey wallets above: no user key on the server to steal.

Forged texts plus codes read from a log


Severityhigh
Worst-case loss now$53

Each user wallet, up to $200 each, every day until the secret changes


Needs. The SMS webhook secret, to send texts as any number, and a way to read the codes the operator texts back: a read of D1 (the message log keeps every outgoing text, codes included), the SMS provider's logs, or the language model provider's logs (recent texts go to the model as history).

Worst case. Forge "withdraw all to <attacker>" from each person, read the code back, and forge the code. Accounts without a passkey lose their balance up to $200 a day; accounts with one, in payments of $20 or less, up to the same $200.

Impact. Everyone without a passkey, repeating daily until the secret is rotated.

Already in place

  • The webhook secret is at least 32 characters, compared in constant time.
  • Codes are stored hashed with their payment and allow 5 tries counted before comparing. A send's code expires in 10 minutes.
  • The $50 per-payment and $200 daily caps are checked again when a payment is approved.

How to harden it

  1. Stop keeping codes: blank out 6-digit codes in the message log and in the history sent to the model.
  2. Authenticate the webhook with the provider's signature and a timestamp, instead of a secret in the URL path that can end up in request logs.
  3. Require a passkey for every withdrawal to an outside address, whatever the amount.
  4. Hold the first withdrawal to a new address for 24 hours, with a text that can cancel it.

The escrow attestor key


Severityhigh
Worst-case loss now$12

Every live escrow (8)


Needs. SOLANA_ESCROW_ATTESTOR_SEED.

Worst case. The escrow program pays an escrow to whichever recipient the attestor names. The attacker claims every live escrow to an address of their own before it expires.

Impact. Recipients never get what they were sent, and senders can't take a refund because the escrow already paid out.

Already in place

  • Only the attestor's signature can claim, and only before expiry.
  • After expiry the money can only go back to the sender, and anyone can send that refund without the operator.

How to harden it

  1. Fix the recipient when the escrow is made: open the recipient's wallet at send time, record it in the escrow, and let claim pay only that address. A stolen attestor key can then only deliver money to its rightful owner.
  2. Require k of n attestors on separate infrastructure to claim.
  3. Keep the attestor key in a KMS that signs only claims for escrows the ledger holds.

The escrow program's upgrade authority


Severityhigh
Worst-case loss now$12

Every live escrow (8)


Needs. The program's upgrade authority key (GFFm…gs88). Today it is a key file on a developer's laptop.

Worst case. Deploy a new version of the program that sends every vault to the attacker.

Impact. Everything the program promises (refunds without the operator, claims only by the attestor) stops holding, for every escrow at once.

Already in place

  • The program and its authority are public on the explorer.
  • The authority is not on the server.

How to harden it

  1. Move the upgrade authority to a multisig (Squads) with a timelock, so an upgrade needs several people and waits.
  2. Once the program is audited, make it immutable.
  3. Publish a verifiable build, so anyone can check the deployed program matches this repo.

A bug in the escrow program


Severitymedium
Worst-case loss now$12

Every live escrow (8)


Needs. A flaw in one of dial_escrow's account checks.

Worst case. Pass the program an account it shouldn't accept and drain vaults.

Impact. Recipients and senders of escrowed payments.

Already in place

  • Every account is checked: the escrow and vault by their derived addresses, the attestor, payer, mint and sender against the escrow, the payout against the owner's associated token account, and the expiry.
  • Tests cover every instruction in a local validator, on devnet, and in a staging round trip on every deploy.

How to harden it

  1. An outside audit of the program.
  2. Fuzz the instructions (Trident) with hostile accounts.
  3. Make it immutable after the audit, with a verifiable build.

The treasury key


Severitymedium
Worst-case loss now$55.77

The treasury's USDC, plus its SOL


Needs. SOLANA_TREASURY_SEED.

Worst case. Sweep the treasury's USDC and SOL. Without SOL the treasury can't pay network fees or rent, so every Solana payment stops.

Impact. Solana payments stop until a new treasury is funded. People's own balances are untouched.

Already in place

  • The treasury holds only what test drips and fees need, and is topped up by hand.

How to harden it

  1. Keep the hot treasury to about a day of drips and fees, and top it up from a cold wallet or a multisig.
  2. A KMS policy so the treasury key signs only drips of the set size and fee payments.
  3. An alert on any treasury transfer that isn't a drip or a fee.

Someone takes over a person's phone number


Severitymedium
Worst-case loss now$21

The largest single wallet, up to $200 a day


Needs. The person's texts: a SIM swap, a port-out, a recycled number, or their unlocked phone.

Worst case. Approve withdrawals with the texted codes. Without a passkey: the whole balance, up to $200 a day. With one: payments of $20 or less, up to $200 a day. Plus any escrow waiting for that number.

Impact. One person per number taken over.

Already in place

  • Per-payment ($50) and daily ($200) caps, checked again at approval.
  • A passkey above $20, once added.

How to harden it

  1. Check with the carrier for a recent SIM or number change before money leaves, and hold withdrawals for 24 hours after one.
  2. Require a passkey for any withdrawal to an outside address.
  3. Ask for a passkey once someone keeps more than a small balance.

Farming the free test money


Severitylow
Worst-case loss now$55.77

The treasury's USDC


Needs. Many phone numbers that can get texts (internet phone numbers), or forged texts from made-up numbers.

Worst case. Every new number gets the $2 signup drop. Real numbers can withdraw it; made-up ones strand it in wallets nobody can spend. Either way the treasury drains $2 a number.

Impact. New people stop getting test money until the treasury is topped up. Testnet only: there is no drop on mainnet.

Already in place

  • One drop per person per chain, taken atomically.
  • No drop at all on mainnet.

How to harden it

  1. Give the drop after a first real action, such as receiving a payment, instead of at signup.
  2. Or point people to a faucet they claim themselves.

Forged texts, without the codes


Severitylow
Worst-case loss now$0

Nothing leaves anyone's wallet


Needs. The SMS webhook secret alone.

Worst case. Send texts as any number: STOP someone, decline their requests, rename them, revoke their agents, or open an account and claim an escrow into that person's own wallet before they reply. No money leaves, because codes go to the real phone.

Impact. Confusion and nuisance, and claims that happen before the real person answers.

Already in place

  • The secret is long and compared in constant time.
  • Every money movement out still needs the code texted to the real phone.

How to harden it

  1. Verify the provider's signature on every webhook, and check the message is addressed to the operator's number.
  2. Keep the secret out of URLs, and rotate it.

A stolen agent key


Severitylow
Worst-case loss now$0

Every payment still needs the owner's code


Needs. A dial_ak_ key from an AI agent's settings.

Worst case. Read the owner's balances and history, and start up to 20 payments an hour. Each one needs the owner's texted code, so a tired owner might approve one.

Impact. Privacy, and approval fatigue. Keys never expire, and STOP doesn't revoke them.

Already in place

  • Keys are stored hashed and shown once.
  • 60 calls a minute and 20 money calls an hour per key.
  • The code text names the agent that asked, and REVOKE AGENTS cuts every key.

How to harden it

  1. Expire keys after 30 days, and revoke them on STOP.
  2. Scopes per key: read-only by default, money only to an allowlist of recipients.

Guessing a code


Severitylow
Worst-case loss now$0

Nothing leaves anyone's wallet


Needs. A way to text as the person (above) without seeing their codes.

Worst case. Five guesses at a one-in-a-million code: one chance in 200,000 per payment.

Impact. Effectively none.

Already in place

  • Codes come from a secure random source, are hashed with their payment, and allow 5 tries counted before comparing. A send's code expires in 10 minutes.

How to harden it

  1. Limit how many new payments one number can start in an hour.
  2. Compare code hashes in constant time.

Prompt injection through the language model


Severitylow
Worst-case loss now$0

Nothing leaves anyone's wallet


Needs. A text written to steer the model that reads unclear messages.

Worst case. The model can only propose a send, request or invoice. The person still gets "Send $X to Y? Reply <code>" and has to answer it. Recent texts, codes included, go to the model provider as history.

Impact. A misleading proposal at worst, and codes visible to the model provider.

Already in place

  • The model can't approve, release or withdraw anything.
  • Every proposal shows its amount and recipient before the code.

How to harden it

  1. Blank out codes before history goes to the model.
  2. Keep the model's commands to proposals and reads.

A stolen web wallet session


Severitylow
Worst-case loss now$0

Nothing leaves anyone's wallet


Needs. Someone's wallet sign-in cookie, or a sign-in code they were texted.

Worst case. See their balances, addresses and activity, and revoke their agents. A session can't move money.

Impact. Privacy.

Already in place

  • Sign-in codes last 15 minutes with 5 tries.
  • The cookie is HttpOnly, Secure and SameSite=Lax, and lasts 7 days.
  • Cross-site sign-in posts are refused.

How to harden it

  1. Also refuse sign-in posts with no Origin or Sec-Fetch-Site header.
  2. Shorter sessions, and sign-out everywhere by text.

A lying RPC node


Severitylow
Worst-case loss now$0

Nothing leaves anyone's wallet


Needs. Control of the Solana RPC endpoint the server reads.

Worst case. Report false balances or confirmations, so the ledger says a payment landed when it didn't. Transactions are still checked by the chain itself, so it can't move money.

Impact. A ledger that disagrees with the chain until someone reconciles it.

Already in place

  • Every payment links to its transaction, so anyone can check the chain directly.

How to harden it

  1. Confirm money-releasing transactions on a second RPC provider, and alert when they disagree.

What the public pages show


Severitylow
Worst-case loss now$0

Nothing leaves anyone's wallet


Needs. Nothing.

Worst case. The ledger shows @handles, amounts and transactions, and pay pages show wallet addresses, so anyone can read a person's balance on chain. Phone numbers are never public.

Impact. Privacy: payments between handles are public by design.

Already in place

  • Phone numbers stay private; people who haven't joined show as "someone new".

How to harden it

  1. Let people hide their payments from the public ledger.
  2. A fresh deposit address per payment, or shielded payments (built for Zcash).