Skip to content

Unblock gold purchases and harden the gold app - #1388

Merged
ebma merged 25 commits into
stagingfrom
fix/gold-review
Sep 30, 2026
Merged

ebma merged 25 commits into
stagingfrom
fix/gold-review

Conversation

@ebma

@ebma ebma commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

A review of apps/gold (ouro. by Vortex, live at /pt-br/gold/) found that no real purchase can complete in production, plus recovery, security and UX defects. This PR fixes what can be fixed safely in the gold app and its Netlify config. API-side findings are listed at the end for follow-up. Every commit is self-contained and passes the gold suite.

Blocking: KYC readiness used a credential-only endpoint

getBrazilBuyReadiness called /v1/ramp-info through the SDK. That route only accepts API credentials (validatePublicKey, apiKeyAuth) and reports on the credential owner, never on the OTP session.

The production build sets no VITE_VORTEX_PUBLIC_KEY: the inlined env object in the live bundle only contains VITE_SIGNING_SERVICE_PATH, and GET /v1/ramp-info answers 401 CREDENTIAL_REQUIRED. So every buy stopped after the e-mail code with "A public or secret API credential is required", and every sell looped back to the code.

Gold now reads the session user's own Avenia account through /v1/brl/getUser, which uses the same resolution as ramp registration. It skips KYC only on identityStatus === "CONFIRMED", as validateKyc.actor.ts does.

Recovery

  • Resuming a sell. GET /v1/ramp/:id returns unsignedTxs only with showUnsignedTxs=true, and the SDK never sends that. So "Continuar esta venda" always failed after a declined wallet prompt, a reload or a receipt timeout.
  • Abandoned ramps locked the device. Both buttons resume the stored operation, and it was only cleared on a terminal status. Unstarted ramps never become terminal, and failed sells were never cleared.
    • Now a ramp still initial one hour after createdAt counts as failed. The unhandled-payment worker starts paid PIX ramps well before that (it runs every 15 minutes).
    • Failed sells are now cleared, like failed buys.
  • Polling. One failed status request ended polling and sent the buyer to the support screen. Transient errors (no response, 408/425/429, 5xx) are now retried, up to 5 in a row.
  • PIX countdown. It counted timer ticks, which pause while the user is in the banking app, and it invented 10 minutes on resume. It now reads the clock and uses createdAt + 15 min when a status response has no expiresAt.
  • Session refresh. Any refresh error cleared the session. The security spec (Supabase OTP rule 9) allows teardown only on 401.

Correctness and UX

  • Price chart. Only 42 days were fetched, so "1A" and "Tudo" showed six weeks, and the badge always showed the 42-day change (−2.6 % live under 1A, against +10.8 % over the actual year). The chart now fetches 365 days, drops "Tudo" (the public CoinGecko API caps history at a year) and shows each period's own change in pt-BR format.
  • OTP e-mail language. The OTP e-mail arrived in English, and the API reset the user's stored locale to en-US. Gold now sends locale: "pt-BR", as the widget does.
  • Repeated e-mail codes. Every buy and sell asked for a new e-mail code, even with a valid Vortex session in the same tab. Like the widget's stored user e-mail, the session now keeps the address it was verified for and is reused only for that address; a rejected session falls back to the code step. The second sign-in itself (Privy for the wallet, Vortex OTP for the ramp) stays by design.
  • KYC country code. country and countryOfTaxId change from "BR" to "BRA", matching packages/kyc. The API passes both unchanged to Avenia.
  • Legal links and contrast. The login note now links the pt-BR terms and privacy policy it says the user accepts. Legal and fee notes go from 11px #837d74 (4.0:1) to 12px #6b665f (5.6:1).

Security, CI and performance

  • Headers. Netlify now sends these headers for /pt-br/gold/*:

    • frame-ancestors 'none' and X-Frame-Options: DENY, because the page signs transactions and collects PIX keys.
    • nosniff, a referrer policy and a permissions policy.

    The standalone gold.satoshipay.io release had the latter set; the Netlify move dropped them. The headers are scoped to gold, so the widget stays frameable.

  • Tests in CI. The root test script and the test job in .github/workflows/ci.yml now run gold's node --test suite (bun run test:gold), which never ran before.

  • Images. The landing hero goes from a 1.96 MB PNG to a 209 KB WebP, and the wordmark from 273 KB to 34 KB.

Added in review

  • Buy amount locked while quoting. With a reusable session the amount step requests the quote itself; an amount typed meanwhile was shown as the purchase value while the PIX is for the quoted amount.
  • Stale error cleared on "Voltar", now that the amount step shows the flow error.
  • Failed operations stay visible. Failed and abandoned operations are cleared so they stop blocking new ones, but now stay in "Movimentações" as "Compra/Venda não concluída" with the full operation code for support.
  • Signing guard. Resuming a sell signs transactions fetched from the ramp status, and neither the SDK nor the API checks what the user's wallet signs. The wallet now only signs the approve of at most the quoted PAXG to Squid's router (0xce16…D666) and the router swap; anything else fails before the wallet prompt. The unused typed-data handler is removed.
  • Start window. updateRamp and startRamp refuse 15 minutes after registration, so a swap sent later (e.g. resuming an old sell) strands the gold. Gold refuses to broadcast within 4 minutes of that deadline and releases the dead ramp.
  • Price arrow points down when the period's change is negative.
  • CI runs the gold tests (🧪 Gold tests in the test job).

Test plan

  • bun run test:gold: 33 pass. There were 17 on staging, and each new test fails there.
  • bunx vitest run src/tests/netlify-headers.test.ts src/tests/netlify-redirects.test.ts in apps/frontend. The headers test fails without the netlify.toml change.
  • bun run build:gold with the production env.
  • Demo-mode walkthrough with no console errors:
    • landing and login links
    • the three chart periods
    • buy to PIX and completion
    • sell to completion
  • The gold test step runs in the CI test job.
  • On the deploy preview, curl -I <preview>/pt-br/gold/ (and /index.html) shows the new headers; /pt-BR/gold/ 301s to it, /pt-br/widget has none.
  • After release, a real low-value buy: the OTP e-mail is in Portuguese, an approved user skips KYC, and the PIX step is reached.
  • A real small sell: decline the wallet prompt once, then "Continuar esta venda".
  • A second purchase in the same tab goes from the amount straight to the review, without a new e-mail code.

Follow-ups

API

Gold

🤖 Generated with Claude Code

alexatsatoshi and others added 18 commits September 29, 2026 14:47
The gold unit tests cover quote validation, recovery checkpoints and gas
preflight, but no root script ran them. Wiring them into
.github/workflows/ci.yml is left to a maintainer (see the PR description).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The standalone gold.satoshipay.io release sent nosniff, referrer and
permissions headers; the Netlify-served /pt-br/gold/ sends none and can be
framed by any site while it signs transactions and collects PIX keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hero PNG was 1.96 MB and the wordmark 273 KB on the first screen;
the WebP is 209 KB and the wordmark keeps 3x its largest display size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chart only fetched 42 days, so 1A and Tudo showed six weeks, and the
change badge always showed the 42-day change whatever the period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One failed status request ended polling and sent the buyer to the support
escalation screen although the ramp kept running server-side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The countdown decremented once per timer tick, and browsers pause timers
while the buyer is in the banking app, so it showed time that had passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fee disclosure and legal notes used 11px #837d74 on #fffdf9 (4.0:1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gold asked /v1/ramp-info whether the user passed KYC, but that route only
accepts API credentials and reports on their owner, never on the OTP
session. Production sets no public key, so every buy stopped after the
e-mail code with "A public or secret API credential is required" and every
sell looped back to the code. /v1/brl/getUser resolves the session user's
approved Avenia account; skip KYC only on CONFIRMED, as the widget does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v1/ramp/:id returns unsignedTxs only with showUnsignedTxs=true and the
SDK status call never sends it, so "Continuar esta venda" always failed with
"Não foi possível recuperar as confirmações" after a declined wallet prompt,
a reload or a receipt timeout, although the copy invites the user to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both buttons resume the stored operation, and it was only cleared on a
terminal status. The API never expires an unstarted ramp, and a failed
sell was never cleared, so one abandoned PIX or failed sell locked the
wallet out of buying and selling on that device for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Status responses have no expiresAt, so a resumed PIX screen invented a
fresh ten-minute countdown; start is refused 15 minutes after creation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gold sent no locale, so the Vortex OTP e-mail arrived in English and the
API reset the user's stored locale to en-US; the template renders
Portuguese for pt-BR, as the widget requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any refresh error cleared the stored session, so a network blip or a 503
from /v1/auth/refresh logged the user out mid-operation. The security spec
(Supabase OTP rule 9) allows teardown only when the refresh returns 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API passes country and countryOfTaxId through to Avenia unchanged. The
widget's KYC (packages/kyc) sends "BRA", gold sent "BR". No unit test: the
payload is built inline in KycStep, which the node test runner cannot load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every buy and sell asked for a new e-mail code, even with a valid Vortex
session in the same tab. Like the widget, the session now keeps the e-mail
it was verified for and is reused only for that address; a rejected session
falls back to the code step. "Voltar" no longer leads into a code step the
user may have skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit 082d0fe
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6abbc1e51e8e0f0008ceb940

@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 082d0fe
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6abbc1e52532db00071c258c
😎 Deploy Preview https://deploy-preview-1388--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi ready!

Name Link
🔨 Latest commit 082d0fe
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6abbc1e535ae930008927328
😎 Deploy Preview https://deploy-preview-1388--vortexfi.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

ebma and others added 7 commits September 29, 2026 15:21
With a reusable session the amount step now requests the quote itself, so an
amount typed meanwhile was shown on the review as the purchase value while
the PIX is for the quoted amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The amount step now shows the flow error, so going back from a wrong e-mail
code or a refreshed quote left that message under the amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Failed and abandoned operations are now cleared so they stop blocking new
ones, which also removed the only place on the device that showed their
operation code; support needs it when a PIX was debited or a sell stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resuming a sell now signs transactions fetched from the ramp status, and
neither the SDK nor the API checks what the user's wallet signs. Only the
approve of at most the quoted PAXG to Squid's router and the router swap
may reach the wallet; the unused typed-data handler is dropped, so any
other transaction kind fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API refuses to record or start a ramp 15 minutes after registration,
so a swap sent later, for example when resuming an old sell, moves the
gold to the ephemeral account of a ramp that can never run. Stop four
minutes before the deadline and release the dead ramp instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ebma
ebma merged commit caa21cb into staging Sep 30, 2026
6 checks passed
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.

2 participants