Skip to content

Claude/orchestra onramp impl 2865ab - #2578

Open
mul53 wants to merge 13 commits into
qafrom
claude/orchestra-onramp-impl-2865ab
Open

mul53 wants to merge 13 commits into
qafrom
claude/orchestra-onramp-impl-2865ab

Conversation

@mul53

@mul53 mul53 commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

mul53 and others added 12 commits September 21, 2026 18:57
Adds a deposit route where the user pays a BOLT11 invoice with Cash App,
Strike or any Lightning wallet, and Orchestra swaps and delivers USDC to
their Safe on Base. Entered from "Deposit with cash", it runs as four
steps in the Add-funds modal: amount, invoice, status, error.

Implements https://docs.flashnet.xyz/orchestra/onramp:

- POST /v1/orchestration/onramp for the order and its invoice, with a
  per-call X-Idempotency-Key.
- GET /v1/orchestration/estimate for live pricing while the user types,
  and /limits for the fiat band the form validates against — both
  fetched rather than hardcoded, because the band is operator-tuned.
- GET /v2/orchestration/routes for the destination's decimals. Every
  amount on the wire is in smallest units, and two assets sharing a
  ticker on different chains do not share an exponent.
- SSE at /v1/sse/operations/:id for user-facing progress, falling back to
  the documented 3-second poll. EventSource is a browser API and React
  Native has none, so native builds poll; the poll also backs up the
  stream on web, since reconnects do not replay missed transitions and
  frames carry only the status.

The flow authenticates with the *client* key (fnp_), whose scopes —
orders:onramp, orders:read, orders:sse — are exactly these calls, plus
the order-bound read token /onramp returns. The app is a static export
with no server of its own, so there is no proxy to hide a server key
behind and none is used: ORCHESTRA_SERVER_KEY is declared in
.env.example without an EXPO_PUBLIC_ prefix, so Expo cannot inline it,
and is left for the backend that owns webhooks and history.

Order state that is not delivered is handled rather than flattened into
a spinner: `unfulfilled` says a late deposit can still settle, `refunding`
says the money is coming back instead of showing a delivery ladder, and
an expired read token says we can no longer track the deposit — not that
it failed.

Env vars added: EXPO_PUBLIC_ORCHESTRA_CLIENT_KEY (public),
ORCHESTRA_SERVER_KEY (secret, backend only),
EXPO_PUBLIC_ORCHESTRA_API_BASE_URL, and the destination chain/asset. The
entry row is hidden until the client key is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Probing the live API showed GET /v1/orchestration/estimate takes `amount`
in sats and has no fiat parameter — `amountFiatUsd`, `fiatUsd`, `usdAmount`
and `amountUsd` all answer 400 invalid_request, and there is no v2. The
amount screen types in dollars, so it was sending a query the endpoint
never accepts: every keystroke would have 400'd.

There is no spot endpoint to convert with either, and inventing a rate from
a price feed the app would have to add means quoting against a number
Orchestra did not agree to. POST /onramp is the first call that takes
`amountFiatUsd`, and it answers with the real sats, fee and delivery at
Orchestra's own spot — so the invoice screen becomes the review step. It
already showed the amounts; it now shows the fee too, and nothing is
charged until the invoice is paid, so back is a real way out.

Also from the live response: the fee is `totalFeeAmount`, not `feeAmount`
— the latter omits the rounding component the user still pays — and
`feeAssetDetails.decimals` states the fee asset's own exponent, which
replaces the ticker-sniffing heuristic that would have printed a sats fee
as "0".

Removes getOrchestraEstimate, useOrchestraEstimate and the debounce and
pending-state machine they needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app no longer holds an Orchestra key or talks to Orchestra directly.
Every call goes to /accounts/v1/orchestra on the accounts service, which
holds the server key.

This is not only about where a secret lives. With a client key on the
device, `recipientAddress` was a field in a body the user controls, and
reading an order needed only its id plus a token the same response handed
back. Both are now the server's to decide: the recipient is the Safe on
the authenticated user's record, and a status read is checked against
that address, so an order id is not enough to read someone else's
deposit.

- lib/api/orchestra.ts calls our backend, JWT on native and cookies on
  web, wrapped in withRefreshToken like every other authenticated call.
- /limits and /v2/routes collapse into one `config` call: the amount
  screen needs the band, the decimals and the symbol together, and three
  round trips to render one form is three chances to half-render it.
- The SSE stream is our proxy. EventSource cannot set headers but can
  send cookies, which is how web sessions authenticate here anyway;
  native has no EventSource and keeps the 3-second poll.
- The entry row is gated on config succeeding rather than on a bundled
  flag — the backend 503s when it has no key, so availability is its
  answer, not a constant that can drift out of step with it.

Drops EXPO_PUBLIC_ORCHESTRA_* entirely, along with fiatLimitsFromResponse
and its tests: picking the fiat band out of the routes list is the
backend's job now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row was gated on the backend's config call succeeding, so a backend
without an Orchestra key — or an unreachable one — made the deposit
option disappear entirely. That is indistinguishable from a feature that
was never built: there is nothing to tap, no error, and no way to tell
the two apart from the app.

Show the row unconditionally and let the amount screen state the actual
reason. A 503 from a keyless server now reads "The server has no
Orchestra key" on the error screen instead of silence.

Also aligns the client's error codes with the ones the backend actually
sends. ORCHESTRA_NOT_CONFIGURED, ORCHESTRA_NO_WALLET_ADDRESS and
ORCHESTRA_ORDER_NOT_YOURS had no entry in the copy table, so every one of
them fell through to the generic "something went wrong" — the three
failures most likely during setup were the three that explained
themselves least.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
components/BuyCrypto/Orchestra sat inside the directory TransFi occupies,
which reads as though Orchestra is a variant of it. It isn't — it is a
separate provider on a different rail, sharing no code with TransFi. The
only thing the two have in common is that I used TransFi as the reference
for this codebase's conventions while writing it.

components/Orchestra now, with BuyCrypto left to TransFi and the legacy
Mercuryo iframe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
account_pending_approval had an action but no copy, so it fell through to
the generic "try again in a moment" — advice that can only waste the
reader's time, since Flashnet reviews new partner accounts before
activating their keys and no amount of retrying changes that. The key is
valid; it simply isn't live.

Same for origin_not_allowed and scope_required, which are key
configuration rather than anything a user can act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The invoice screen threw "Cannot convert a BigInt value to a number" as
soon as an order was created. formatSats and formatSmallestUnits both
passed a BigInt to Intl.NumberFormat.format. The spec allows that, but
Hermes and the Intl shim under React Native Web coerce the argument with
ToNumber first, which throws.

Node's Intl accepts a BigInt, so the existing tests passed while the app
was broken — the one runtime the tests don't use was the only one that
mattered.

Groups the digit string directly instead, taking the locale's group and
decimal separators from Intl once via a plain Number. That keeps the
exactness BigInt was chosen for, and also fixes the decimal point, which
was hardcoded to "." regardless of locale. formatSats needs no BigInt at
all — it is already a digit string.

Adds a test that patches Intl to reject BigInt the way the app's runtime
does, so this cannot pass in CI while failing on device again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to how the cash deposit flow is laid out.

USD is now a chooser rather than a shortcut. Selecting it opens "Deposit
US Dollars" with two rails — "Wire transfer, ACH" (the virtual account's
own details) and "Cash App" (the Lightning onramp). The standalone Cash
App row is gone from the currency list: it was never a currency, and
sitting beside USD, EUR and BRL implied it was one.

Outside the US, USD still opens the virtual account directly. A chooser
with one option is a tap that asks a question with one answer.

And the Lightning rail is US-only. That is not our restriction to relax:
Cash App's own Lightning send and receive is US-only, and Orchestra
additionally excludes New York City, so the row would otherwise produce
an invoice the user has no way to pay. The gate is applied twice — on the
row, and again on the amount screen, which is reachable by other routes
(a restored modal step, a user whose IP moved between sessions).

An unresolved country counts as unavailable rather than as permission: a
US-only rail is not something to offer on the strength of not knowing
where someone is.

Extracts useVirtualAccountEntry, since the bank rail is now reached from
two screens and duplicating its Wirex/Rain routing would have been the
second copy to drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chooser sits between picking USD on the cash screen and picking a
rail, so without a view event the two read as one step and the drop-off
between them is invisible — including how many people reach it and take
neither rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lightning is the rail, not the thing the user recognises. Every visible
string now names Cash App: the step titles ("Deposit with Cash App",
"Pay with Cash App"), the row subtitle, the QR caption, the status line,
and the invoice label, which reads "Payment request" rather than naming a
protocol nobody asked about.

Dropped "Strike, or any Lightning wallet" rather than restating it — the
invoice is still a BOLT11 and other wallets still pay it, but leading
with the rail made a one-tap Cash App handoff read like something
technical.

The Cash App row's chips lose "Lightning" too: the row is already titled
Cash App, so the chip says how fast, not how.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend now reports `isAvailable` on /orchestra/config — its verdict
on the launch flag and the username allowlist. The row is hidden when it
says no, and the amount screen sends anyone who reaches it anyway to the
error screen rather than letting them mint an invoice the server will
refuse.

Two independent gates, and both must pass. Region is resolved on the
device because only the client can see the user's IP; audience is the
server's, because hiding a row is not enforcing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client was ANDing its own region check with the server's verdict,
which is the wrong shape now that the rule is "US **or** allowlisted" —
an allowlisted tester abroad was hidden from the feature by the half of
the rule that was supposed to be overridden.

The client now resolves the country only to pass it along, and gates on
config.isAvailable alone. One rule, evaluated once, on the side that also
enforces it.

Drops ORCHESTRA_REGION_UNSUPPORTED: the region verdict is no longer the
client's to reach on its own, so the code had no way to fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated
solid-app Ignored Ignored Preview Sep 24, 2026 3:05pm UTC
solid-app-staging Ignored Ignored Preview Sep 24, 2026 3:05pm UTC

Request Review

const setModal = useOrchestraNavigation();
const order = useOrchestraStore(state => state.order);
const amountUsd = useOrchestraStore(state => state.amountUsd);
const { data: config } = useOrchestraConfig();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The useOrchestraConfig hook is called without a countryCode in OrchestraInvoice.tsx, causing a React Query cache miss and a redundant API call after navigating from the Amount screen.
Severity: HIGH

Suggested Fix

The countryCode is available in both the Invoice and Status screens via the useCashAppDepositAvailability hook. Pass the countryCode to the useOrchestraConfig hook in OrchestraInvoice.tsx and OrchestraOrderStatus.tsx to ensure the cache key matches the one set by OrchestraAmount.tsx. This will prevent the cache miss and the redundant API call.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: components/Orchestra/OrchestraInvoice.tsx#L60

Potential issue: A cache key mismatch in React Query causes a temporary data
availability issue. The `OrchestraAmount.tsx` component calls
`useOrchestraConfig(countryCode)`, caching the result under a key like
`['orchestraConfig', 'US']`. However, `OrchestraInvoice.tsx` and
`OrchestraOrderStatus.tsx` call `useOrchestraConfig()` without the `countryCode`,
resulting in a different cache key `['orchestraConfig', undefined]` and a cache miss.
This triggers a new, redundant API call. While this call is in-flight, `config` is
`undefined`, causing `formatSmallestUnits` to return `undefined`. Consequently, the user
sees "You receive: Not available" on the invoice review screen until the new network
request completes.

Also affects:

  • components/Orchestra/OrchestraOrderStatus.tsx:61

Did we get this right? 👍 / 👎 to inform future reviews.

Four conflicts, three of them parallel additions and one a rename.

constants/modals.ts — qa added OPEN_ONRAMPER_WIDGET at number 28, which
the Orchestra steps had also claimed; they move to 29-32. qa also renamed
OPEN_DEPOSIT_CHAIN to OPEN_DEPOSIT_TOKEN, so its doc comment is taken
from qa rather than merged.

hooks/useDepositOption.tsx — six hunks, all "both sides added a branch to
the same chain", kept both. The getContent hunk needed care: each side's
final `if` was left open inside the conflict and shared the brace after
it. isDepositChain no longer exists, so those references are dropped in
favour of qa's isDepositToken.

components/DepositOption/DepositCashOptions.tsx — only the imports
clashed; the body merged. Neither side's contested imports survive it: qa
dropped the live TransFi payment-method fetch for
getCardFundLocalPaymentMethods, and the Rain/virtual-account plumbing had
already moved into useVirtualAccountEntry.

The 2 failures in components/Rewards/NewRewards/__tests__/skipTheLine
predate this merge — they fail identically on a clean origin/qa worktree
with none of this branch's code present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Sep 24, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant