Skip to content

Create new production release - #1277

Merged
ebma merged 141 commits into
mainfrom
staging
Jul 20, 2026
Merged

Create new production release#1277
ebma merged 141 commits into
mainfrom
staging

Conversation

@ebma

@ebma ebma commented Jul 20, 2026

Copy link
Copy Markdown
Member

No description provided.

Sharqiewicz and others added 30 commits June 23, 2026 19:45
…hain/vortex into feature/dashboard-app-staging
… dual-write)

Realizes the complete data model from the two architecture docs in one migration
set, per the amended plan (migrate data once, cut reads over next, keep legacy
tables as read-only backup):

- 038 customer_entities + backfill (one individual entity per profile)
- 039 partners split: partner_pricing_configs, dedup to unique-name partners,
  FK repoint (quotes/assignments) before fold, partners_legacy snapshot,
  assignments partner_id collapse, idempotent vortex re-seed
- 040 provider_customers + kyc_cases + backfills from mykobo_customers,
  alfredpay_customers, tax_ids (owned rows only; statuses verbatim; raw tax ids
  replaced by sha256 hash + masked display; ownerless rows quarantined)
- 041 api_keys.partner_id FK (backfill from partner_name) + scopes + revoked_at
  (user_id from 034 already is the doc's profile_id)
- 042 recipient_invitations (link-based, D1) + sender_recipients +
  recipient_payout_references (thin provider pointer, D3)
- 043 notifications + notification_preferences

All new tables ENABLE ROW LEVEL SECURITY (Supabase default privileges grant
anon/authenticated ALL on future tables; RLS-no-policy denies PostgREST).
New status columns are VARCHAR + CHECK (plan D6).

Verified: full chain 001-043 clean on fresh PG16; backfills exercised against
seeded legacy fixtures (dup alfredpay rows, IND+BUS same country, multi-tax-id
user, empty-string subaccount sentinel, orphan tax_id, divergent per-direction
partner activity, ghost partner_name key) with all assertions passing.

NOTE: not independently deployable — existing code still reads the pre-split
partners shape; the cutover commits follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Milestone 2 of docs/features/contract-tests.md: consumed-contract zod
schemas for configs, quotes (both directions), onramp/offramp order
creation and polling, fiat accounts, KYC status, and the 409 trade-limit
error body — parsed against the fake (hermetic, PR-blocking) and the
partner API (live, nightly). Order-creating live tests are gated behind
pre-provisioned sandbox fixtures and stop before any payment.

Live verification surfaced a wire-type lie: the limit-breach
errorMetadata quantities arrive as JSON numbers, not strings — the
client now stringifies them at the boundary so
AlfredpayTradeLimitError.quantity stays a truthful string.

getQuote has no production consumers and is deliberately uncovered.
Milestone 3 of docs/features/contract-tests.md: consumed-contract zod
schemas for quotes, pix-key validation, PIX ticket creation/listing,
payout ticket status, limits, balances, and subaccount info. The
subaccount-scoped live tests are gated behind a pre-provisioned sandbox
subaccount; the only transaction created per run is a PIX pay-in ticket
that expires unpaid. Payout tickets are covered hermetically only —
creating one live would move funds.

Resolves the wire-type lie the PRD calls out: BaseTicket timestamps and
PixInputTicketOutput.expiration are ISO strings on the wire, not Date
(nothing ever consumed them as Date). The contract also caught FakeBrla
drift: subaccountInfo served an empty accountInfo while production reads
accountInfo.identityStatus — now scriptable on the fake.

createOnchainSwapQuote/Ticket, getMainAccountBalance and
getAveniaSwapTicket have no production consumers and are deliberately
uncovered.
Milestone 4 of docs/features/contract-tests.md: the simple/price shape
getCryptoPrice consumes, as a schema next to priceFeed.service.ts (its
future Milestone-5 warn-only parse site). FakePrices patches above the
HTTP seam, so the hermetic half is fixture-based; the live half mirrors
the service's request construction and asserts presence of the requested
ids. Falls back to the keyless public API when no COINGECKO_API_KEY is
set. Nabla/DIA rates are chain state — out of scope per the
no-chain-fidelity non-goal.

Adds zod to apps/api (catalog version, already a transitive dependency
via shared).
The nightly contracts workflow now passes sandbox credentials and
pre-provisioned fixtures from repository secrets (CONTRACT_ALFREDPAY_*,
CONTRACT_BRLA_*, CONTRACT_AVENIA_SUBACCOUNT_ID, COINGECKO_API_KEY).
Until those are provisioned, missing secrets resolve to empty and
CONTRACT_EXPECT_LIVE fails the run loudly — the intended rot alarm, per
the PRD.

.env.example documents the BRLA credentials and the sandbox-only
contract fixtures; testing-strategy.md and the PRD status line reflect
milestones 1-4 being implemented, with milestone 5 (warn-only production
parsing) shipping separately per endpoint after a quiet week of
nightlies.
zod was added to packages/shared and @vortexfi/sdk pinned shared to =0.2.0
without a lockfile update; the missing zod entry broke bun build:shared.
Pure bun install output — no package.json changes here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Existing functionality unchanged; pricing now resolves through the split
schema (migrate-once cutover, legacy columns unread):

- New partners/partner-pricing.service: findPartnerWithPricing merges the
  unique-name partner row with its per-direction pricing config — the exact
  semantics of the pre-split Partner.findOne({name|id, isActive, rampType})
- partner-resolution: assignment branch reads the collapsed
  profile_partner_assignments.partner_id (buy/sell pair now unread backup)
- quote-fees: single pricing-config lookup replaces the findAll loop;
  CalculateFeeComponentsRequest.partnerName renamed to partnerId (it always
  carried an id)
- discount helpers: ActivePartner sourced from pricing configs; in-memory
  dynamic-difference state keyed by (partner_id, ramp_type) to preserve the
  per-direction isolation the split rows used to give for free
- feeDistribution: vortex + markup payout addresses read from pricing configs
- apiKeyAuth: secret/public keys resolve partners via api_keys.partner_id FK;
  partner_name never read; user-scoped branch keys off partner_id IS NULL
- admin controllers: unique-name partner resolution; assignments write a
  single partner_id; api-key CRUD keyed by partner_id; revocation stamps
  revoked_at; ambiguity 409 retired (structurally impossible)
- factories: createTestPartner creates partner + config (findOrCreate by
  unique name); seedVortexPartners = 1 partner + BUY/SELL configs;
  updatePartnerPricing helper for per-direction test overrides

Verified: full API suite green (414 pass / 0 fail) including the golden
pricing test (fee parity through the split), corridor scenarios and
sdk-contract suites. Security-spec updated in the same change set:
01-auth/api-keys.md, 03-ramp-engine/{profile-partner-pricing,
discount-mechanism,fee-integrity,quote-lifecycle}.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mers/kyc_cases

Migrate-once cutover of the EUR (Mykobo) and BRL (Avenia) provider identity;
existing functionality unchanged (full suite green: 415 pass / 0 fail):

- customer-entity.service: getOrCreateCustomerEntityForProfile — eager create
  in verify-otp (outside the OTP error mapping so a DB failure cannot surface
  as "Invalid OTP") + lazy fallback on every entity-scoped read (covers users
  with pre-existing sessions who never re-verify)
- mykobo-customer.service: KYC mirror upserts/reads provider_customers
  (provider=mykobo, provider_customer_id = last-synced email); module path and
  exports unchanged (integration-test mock seam preserved)
- avenia-customer.service (new): hash/mask helpers, tax-hash lookups,
  kyc_cases upkeep, idempotent Requested→Accepted/Rejected outcome transition
- avenia-account/brla.controller/ramp.service/phase handlers/worker: all
  tax_ids reads keyed by tax_reference_hash against provider_customers;
  ownership checks compare the effective user's customer_entity; SDK-matched
  error strings byte-identical ("Subaccount not found", "KYC invalid", ...)
- createSubaccount keeps claim-on-create: quarantined (ownerless) legacy
  tax_ids rows are adoptable via a one-time read-only probe of the backup;
  owned legacy rows still 409
- Ownership gaps closed (the real remaining Phase 5): fetchSubaccountKycStatus
  (which also writes status transitions) and getSelfieLivenessUrl now enforce
  account ownership. Known residual: getKybAttemptStatus cannot be bound
  server-side (attempt response carries no subaccount linkage)
- Migration 040 amended (pre-deploy): provider_customers.tax_reference retains
  the raw normalized tax id — documented deviation from the "no raw tax IDs"
  non-goal; it is the join/aggregation key for in-flight ramp state and
  already persists in ramp_states.state JSONB
- Quote-provenance writes dropped (write-only columns); tests updated
  (brla.controller.test rewritten for the new model incl. legacy-adoption
  scenarios; factories create provider_customers; unique-subaccount fix in
  sdk-contract)

Security-spec updated in the same change set: 05-integrations/brla.md
(+ cutover section documenting the raw-tax-reference deviation and the KYB
residual), 05-integrations/mykobo.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odels

Completes the provider cutover (full suite green: 415 pass / 0 fail):

- alfredpay-customer.service (new): findAlfredpayCustomer resolves the
  caller's customer_entity and preserves the legacy updatedAt-DESC tie-break;
  a legacy-shaped view keeps the controller's status machine verbatim
  (alfredPayId/type/status accessors); status transitions mirror into the
  account's kyc_cases row; createAlfredpayCustomer maps country → rail
- alfredpay.controller: all 22 lookup/create sites ported (mechanical);
  status vocabulary unchanged — and UPDATE_REQUIRED writes now succeed
  (the legacy Postgres enum lacked the value the shared enum had)
- alfredpay.helpers/quote resolvers: customerType ASC keeps the type-ASC
  precedence; KYC gates read provider_customers
- Removed models with zero remaining readers: AlfredPayCustomer,
  MykoboCustomer, KycLevel2 (tables stay as read-only backup). TaxId model
  retained solely for the createSubaccount legacy-adoption probe

Security-spec updated: 05-integrations/alfredpay.md (+ cutover section),
07-operations/api-surface.md and 03-ramp-engine/quote-lifecycle.md
resolution chains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dashboard backend surface (plan §7/§8/D5), reading the new tables directly:

- /v1/recipients: link-token invites (sha256-hashed at rest, 14-day TTL,
  optional email binding), token-bound acceptance creating the
  sender_recipients relationship, listing, nickname/block/archive PATCH,
  and the transfer-eligibility gate with per-check blocking reasons.
- /v1/notifications: owner-scoped feed + unread count, read/read-all,
  preferences (defaults on first read). emitNotification never throws;
  email dispatch (D7) deliberately not wired yet.
- GET /v1/onboarding/status: per-entity aggregate over provider_customers
  + kyc_cases with a normalized approved/pending/rejected state.

New security specs 03-ramp-engine/recipient-transfers.md and
07-operations/notifications.md; plan Phase 1 marked landed (§7.1 payout
instrument mechanism still open, so verified payout references have no
production writer yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 0 core connection plus the swap-execution engine, ported from the
widget's ramp machine per plan §9:

- Real auth: /v1/auth request-otp/verify-otp/refresh via a ported
  AuthService (dashboard-scoped storage keys) + api-client with bearer
  auth and single-flight 401 refresh; login page drops the fake OTP.
- Real quotes: POST /v1/quotes with a two-pass inversion so the
  payout-driven form ("recipient gets X") maps onto the input-driven
  quote endpoint.
- transfer.machine.ts: the widget's RegisterRamp → UpdateRamp/sign →
  StartRamp → RampFollowUp core as a dashboard-owned XState machine —
  register with fresh ephemerals, presign ephemeral txs client-side
  (signUnsignedTransactions), sign user-owned txs with the wagmi wallet
  (typed-data permits + squidRouter broadcasts, EVM-only), start, then
  poll to terminal. An app-lifetime actor keeps polling after the form
  unmounts and feeds the transactions table.
- CORS: allow :5174 in development; dashboard gains @vortexfi/shared
  (runtime signing helpers), .env.example, esnext build target.

AssetHub payin is disabled (no substrate wallet in the dashboard).
Alfredpay/BRL offramp additionalData stays incomplete until the §7.1
payout-instrument capture lands — EUR is the fully-wirable corridor.
Recipients/notifications/onboarding pages still run on mocks; wiring
them to the new /v1 endpoints is the remaining §9-D work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-verified per corridor: registration is structurally a self-offramp —
destinations are already sender-bound on mykobo (anchor-side IBAN) and
alfredpay (fiatAccountId scoped to the derived customer); only BRL takes a
third-party destination (pix key + receiverTaxId consistency check).
Sender→recipient transfers therefore need a second principal in
registration (resolve the payout side from the recipient's provider
identity), not a bolted-on eligibility check. Recorded as a pressing
open decision (plan §12.7) and corrected in the recipient-transfers spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the mocked recipient store with real data:

- Fetch third-party recipients + pending invitations from GET /v1/recipients
  (recipients.service.ts, recipient.mappers.ts, useRecipients hook).
- Derive self-recipients from fetched payout accounts: one per saved AlfredPay
  fiat account (GET /v1/alfredpay/fiatAccounts), carrying the real fiatAccountId;
  a single entry for BR/EU.
- Wire invite creation to POST /v1/recipients/invite (real one-time token link),
  invalidating the list.
- Transfer selector: only self-recipients are selectable; third-party rows are
  disabled with a "coming soon" tooltip (no third-party sending yet).
- Self-offramp additionalData now matches what the backend derives: AlfredPay
  sends fiatAccountId, BR sends only the user's PIX key (taxId/receiverTaxId are
  derived server-side) via a new PIX-key field, EU sends the receiving wallet
  (anchor email derived server-side).
- Remove the orphaned recipient mock machinery (store recipient state/methods,
  ensureSelfRecipient, recipientFlow simulate, SEED_RECIPIENTS, invite-code/
  profile mocks); persist bumped to v6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drive the approved-corridor gate from GET /v1/onboarding/status instead of
the mocked account onboardings:

- onboarding.service.ts + useApprovedCorridors hook derive the set of corridors
  the authenticated profile is provider-approved for (rail→corridor, with a
  provider+country fallback for null rails).
- useRecipients gates self-recipient synthesis on that real set and returns it
  so the recipients page can offer only invite-able corridors.
- recipients.tsx builds its "Add recipient" corridors from the real approved
  set rather than the mock account's selectedCorridors/onboardings.

Self-recipients (AlfredPay fiat accounts, BR/EU entries) and invite corridors
now reflect the user's actual provider_customers/kyc_cases, not seed data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the mock Zustand store and seed data (SEED_ACCOUNTS/SEED_TRANSACTIONS/
SEED_RECIPIENTS) and drive every surface from the authenticated session:

- Account identity is derived from the Supabase session + GET /v1/onboarding/status
  (type + per-corridor status); no account switcher (one session = one account).
- Transactions come from GET /v1/ramp/history/:walletAddress for the connected
  wallet (transaction.mappers + useTransactions); the transfer actor invalidates
  that query instead of writing a local row.
- Onboarding is a redirect: the mock KYC/KYB wizard, headless/external machines,
  country-select and add-corridor are deleted; overview shows real per-corridor
  status and "Start" opens the widget (?kybLocked=<region> via VITE_WIDGET_URL).
- Funding drops the fake Privy "embedded wallet" (mockPayinAddress) — the ramp is
  signed by the connected wallet, so both funding paths resolve to it.
- Remove now-dead helpers (routeFor/OnboardingRoute, simulated notify* helpers).

Third-party (non-self) recipients remain visible-but-disabled ("coming soon");
that is the only intentional restriction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the provider-status rollup so submitted-and-under-review statuses
(Mykobo PENDING, AlfredPay UserCompleted/Verifying, Avenia Requested) map
to a distinct `in_review` state instead of collapsing into `pending`,
which still means "awaiting the customer". Thread it through the
dashboard's status mapping.

Also point onboardingUrl at /widget, which the widget actually serves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plan routed both sender and recipient onboarding to the widget via
?kybLocked=, and a later commit deleted the dashboard's onboarding wizards
on that basis. That bounced an authenticated sender to another origin with
no return path. Senders now onboard in the dashboard again; recipients keep
the widget link-out for now.

Restores OnboardingWizard / HeadlessFlow / ExternalFlow / WizardStepFields /
DocumentDropzone and their state machines as they were, plus routeFor() and
the CorridorCard route hints.

The wizard submits nothing, so GET /v1/onboarding/status cannot see it. A
session-only useOnboardingOverrideStore overlays wizard-advanced statuses on
the aggregator's real ones so the corridor card advances; a reload drops back
to real provider status. That store is scaffolding and is deleted when the
wizard drives the real KYC machines.

Plan §6 is rewritten accordingly (sender in-dashboard, recipient via widget),
and the follow-up plan gains the reuse-vs-port analysis for lifting the
widget's KYC state machines. Also records two gaps: sender onboarding is
mocked end-to-end, and the widget has no KYB region for EU or AR, so
recipient onboarding is unreachable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ract

AR was absent from KYB_REGIONS by omission, not by decision. alfredpayKyc
already handled AR everywhere it handled MX/CO (plus an AR-only selfie
upload), ArKycFormScreen existed, KYC_CHILD_BY_FIAT mapped ARS to it, and
AlfredPayCountry.AR was already accepted server-side. Adding the region
entry plus both locale strings makes ?kybLocked=AR resolve.

EU stays excluded for the documented reason: Mykobo is individual-KYC-only
and needs a connected wallet, so it cannot complete a quote-less KYB deep
link. Corrects the mappers.ts comment that lumped AR in with EU.

Follow-up plan §3 is reframed. "Move to shared" and "import the file from
the frontend" both fail for the same reason: the machines import the
frontend's module-scoped api-client and provider services, and
vortex-frontend is private with no exports. The blocker is coupling, not
packaging. Proposal is to inject the provider service at the machine
boundary, then extract to a new packages/kyc, starting with alfredpayKyc
(zero ramp coupling, four corridors) so the widget's ramp machine is
untouched.

No server behavior changes, so no security-spec update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Alfredpay KYC/KYB state machine out of the widget into a new
@vortexfi/kyc package so the dashboard can run the real verification flow
instead of a visual mock. Three couplings had to be inverted for the machine
to leave the widget:

- the module-scoped AlfredpayService import became an injected AlfredpayKycApi
  port (the machine no longer boots a Supabase client on import, so the test's
  auth mock is gone);
- window.open became an injected openVerificationUrl (no DOM in the package);
- AlfredpayKycContext no longer extends RampContext — the machine never read a
  ramp field, so kyc.states.ts now passes only { country, business }.

The zod schemas and phone normalisers move into the package too: both apps
resolve zod 4.4.3, and this is the one place a dashboard/widget divergence
would silently build a payload Alfredpay rejects. The widget's form screens now
import them.

Dashboard: the mock wizard collected no data (WizardStepFields submits nothing;
DocumentDropzone has no file input), so real screens were written —
KycFormScreen (MX/CO/AR), DocumentUploadScreen (real uploads, AR selfie), and
AlfredpayKycFlow mapping machine states to screens. OnboardingWizard routes
individual KYC on Alfredpay corridors to the real machine; everything else,
including Alfredpay company KYB, stays mocked (split by account.type). The
dashboard apiClient now passes FormData through for multipart uploads, and the
real flow refetches onboarding status rather than writing the mock override.

Tests: packages/kyc has 36 tests (18 machine incl. one new link-seam case,
18 schema). Frontend suite drops exactly the 17 moved tests; no regressions.
Per-app typecheck clean; both apps build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gianfra-t and others added 24 commits July 16, 2026 13:47
…s Alfredpay requires

The KYB corridor had no contract coverage at all: every KYB fixture in the repo was
asserted only against mocks we wrote from types we also wrote, so nothing had ever met
the provider. Running the real sequence against the sandbox found that Alfredpay's KYB
requirements had expanded without us noticing — sendKybSubmission rejects our payload
with 110002 "Invalid field(s)", naming nine compliance questionnaire fields plus a
fourth company document. No company KYB can be finalized today.

Alfredpay self-describes the per-country set at GET …/penny/kybRequirements?country=
(MEX and MX both resolve). Its required list matches the 110002 rejection exactly. The
questionnaire is sent flat and stored nested under `questionnaire`; the shareholder
registry uploads as fileType `shareholderRegistry` (undocumented — found by probing,
the other spellings 422). `pep` is required for CO/US/AR but not MX.

- AlfredpayKybQuestionnaire on SubmitKybInformationRequest, and SHAREHOLDER_REGISTRY on
  AlfredpayKybFileType. The questionnaire fields are optional only because the KYB form
  does not collect them yet; making them required would force either invented compliance
  answers or a form demanding inputs it lacks. The gap is documented and the live flow
  test is what covers it — the compiler will not.
- alfredpayKybBusinessDetailsResponseSchema pins submissionId + relatedPersons[]
  .idRelatedPerson, the pair the document uploads key off.
- Live details read against a fully populated sandbox company, and an opt-in
  (ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1) end-to-end flow: every call is accepted and every
  shape holds. The submission is then FAILED by sandbox verification within ~30s because
  the uploads are blank placeholder images, which is the correct outcome for fake
  documents — noted in the test so nobody chases it.
- Security spec: the requirement set, the conditional branches, the pep corridor delta,
  and the known UI gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… provider requires

Alfredpay's KYB requirement set had grown past what the form asked for, so no company
KYB could be finalized through the widget or dashboard: sendKybSubmission rejected every
submission with 110002 naming nine questionnaire fields and the shareholder registry.
Nothing caught it because every KYB fixture was a mock written from types we also wrote.

GET .../penny/kybRequirements?country= self-describes the set, and it is the source of
truth here. The questionnaire goes on a new screen between the company form and the
uploads (FillingKybQuestionnaire), is sent flat alongside the company fields, and is
stored by Alfredpay nested under `questionnaire`. Two conditionals mirror the provider's:
transmitsCustomerFunds reveals the compliance-screening pair, isRegulatedBusiness reveals
the business licence and AML policy documents. `pep` is required for CO/US/AR but not MX
— the only field that differs by corridor, so the form always asks it.

The wire type keeps the questionnaire required and the machine splits the form data so
back-navigation restores what was entered. submitKybInformation validates the
questionnaire and its conditionals server-side, so a client that omits them is rejected
with 400 rather than reaching Alfredpay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s KYB

Company KYB failed for real users at the representative's ID upload with a bare
`{"errorCode":111301,"errorMessage":"UNKNOWN_ERROR"}` that names no field. The cause is
the filename: Alfredpay's relate-person endpoint answers any non-ASCII byte in the
multipart filename with a 502, and macOS separates the time from AM/PM with U+202F — so
`Screenshot ... at 12.23.56 PM.png` is rejected while the same name retyped with an
ordinary space is accepted. Accented names ("Identificación oficial.png") fail the same
way. Verified against the sandbox on the customer and related person from the production
log: the failing call succeeds with a blank.png, and every size, mime type and ASCII name
tested passes. The company-document endpoint next door accepts these names, which is why
only this one step broke.

Alfredpay stores every upload under a generated {uuid}.{ext}, so the submitted name is
discarded on arrival and rewriting it loses nothing. Applied to all three uploads.

The sanitizer copies the bytes into a new File, which is load-bearing: under Bun,
new File([file], name), new Blob([file]) and FormData.append(field, file, name) all alias
or ignore their way back to the original name. The first attempt used append's filename
argument and passed against the live sandbox while doing nothing; the test asserts the
name that reaches the wire, which is what caught it.

The contract test never saw this because it sent "blank.png" — faithful to every part of
the flow except the one thing a real user controls. It now drives the flow with the
production filename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…splays

The link text was updated to support@vortexfinance.co but the href still opened
support@vortex.co, so clicking it mailed the old address.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…essful retry

updateAveniaKycOutcome only flipped accounts that were in_review (mirroring
the legacy WHERE internal_status = 'Requested' guard), and the in-flight
poll branch excluded rejected accounts. A user whose first KYC attempt was
rejected and whose retry succeeded therefore stayed 'rejected' forever, and
ramp registration failed with 'No completed Avenia profile found for this
API key user' despite a successful KYC.

Approval is now the only terminal state: a stale attempt read never
downgrades an approved account, but rejected/pending accounts follow the
latest provider attempt, and a processing retry moves a rejected account
back to in_review. Repeated polls of an unchanged outcome no-op.
Pre-existing useSortedClasses violation on staging that fails 'bun verify' in CI.
…roval

fix(api): let a rejected Avenia KYC account become approved on a successful retry
Users whose wallet already holds a sufficient allowance for the squid
router never broadcast the approve tx, so they have no approve hash to
report. fundEphemeral previously parked such ramps forever waiting for
it, even though the user had already paid.

The approve hash is now verified only when reported (with unchanged
rigor); squidRouterSwapHash — the tx that actually delivers funds to the
ephemeral — remains mandatory. Skipping a genuinely-needed approve is
safe: the swap's transferFrom reverts on-chain and the swap hash fails
its receipt check. The verification gate now keys on the squidRouterSwap
blueprint instead of the approve blueprint accordingly.

Also updates the security spec (F-041 notes), the OpenAPI description
of /v1/ramp/update, and adds corridor tests for both the swap-hash-only
happy path and a tampered-but-reported approve hash.
…d oracle rate

The offramp expected-output math multiplies the request input amount by the
inverted FIAT-USD oracle rate, assuming the input is USD-denominated. On the
EVM offramp routes the input can be BRLA or EURC: a 1000 BRLA -> PIX offramp
was treated as 1000 USD, inflating expectedOutput ~5x (and with it the
maxSubsidy cap), over-paying the subsidy on every such quote. EURC -> SEPA
offramps were symmetrically under-subsidized to zero.

Value the input in USD first: USD-like stables pass through, fiat-pegged
stables (BRLA, EURC) are valued at their peg's oracle rate, and other tokens
fall back to the bridged USDC amount. Applied to both OffRampDiscountEngine
and OffRampAlfredpayDiscountEngine, with a quote note whenever the valuation
differs from the raw input amount.
…allback

Address CI failure and PR review comments:

- Update the SELL 100 BRLA → BRL pricing golden from 500.00 to 100.00 BRL.
  The old value encoded the pre-fix bug (100 BRLA valued as 100 USD → 500 BRL);
  100 BRLA is worth ~100 BRL at the 1:1 peg. Only outputAmount changed.
- getUsdDenominatedInputAmount no longer throws on a FIAT-USD rate-feed failure
  for a pegged stable; it falls back to the bridged USDC amount (or raw input),
  so a transient price-feed outage cannot fail the quote from discount math.
- Add EURC valuation + under-subsidy regression tests and rate-failure fallback
  tests to the helper suite.
- Reflect the graceful fallback in the discount-mechanism security spec.
…-denomination

fix(api): value offramp discount input in USD before applying inverted oracle rate
…erPay polling loops

Two fixes for the squidRouterPay phase, prompted by a Base->BNB transfer that
sat in Axelar status "called" for 14h after its validator confirmation poll
failed (Axelar's relayer never retries a failed poll):

- Honor the phase processor's AbortSignal in the status polling loop and
  balance check. Previously every timed-out execution left an immortal 10s
  polling loop behind; retries plus the recovery worker piled up dozens of
  them per stuck ramp (~336 log lines/2min observed in production) until the
  SquidRouter status API rate-limited us with 429s.

- Detect a failed confirmation poll (status "called" + confirm_failed from
  axelarscan) and auto-recover: fetch a signed ConfirmGatewayTx from Axelar's
  public recovery signing service and broadcast it to the Axelar RPC, which
  restarts the validator poll. Uses only the public tx hash - no keys, no
  funds. Attempts are rate-limited via a cooldown timestamp persisted in ramp
  state. The byte handling is done manually because axelarjs-sdk's
  manualRelayToDestChain mangles the relayer's numeric-keyed byte response
  and broadcasts an empty tx ("must contain at least one message").
Addresses Copilot review on #1276:

- recoverAxelarStuckConfirm: validate the relayer response shape (reject
  null/non-object data, ignore non-numeric keys, require integer bytes in
  0-255) instead of silently coercing garbage into a corrupted broadcast.
- Treat a successful broadcast without a returned tx hash as an error
  rather than returning an empty string.
- Thread the phase AbortSignal into the recovery fetches so aborted
  executions stop promptly instead of finishing network I/O after timeout.
- Normalize an unparseable persisted axelarConfirmRecoveryAt to "never
  attempted" so the cooldown comparison stays well-defined.
Addresses Copilot follow-up on #1276: byte-by-byte string concatenation
before btoa is quadratic; encode in 8KiB String.fromCharCode chunks
instead (spreading the full array at once could hit the argument-count
limit).
…t-and-axelar-recovery

fix(api): auto-recover Axelar stuck-confirm and stop leaked squidRouterPay polling loops
…ve-hash

Make squidRouterApproveHash optional in fundEphemeral verification
@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for vrtx-dashboard ready!

Name Link
🔨 Latest commit b81256e
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6a5e35b08d53f50008fa117f
😎 Deploy Preview https://deploy-preview-1277--vrtx-dashboard.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 Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for vortexfi ready!

Name Link
🔨 Latest commit b81256e
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6a5e35b0fc92630008d89154
😎 Deploy Preview https://deploy-preview-1277--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.

@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit b81256e
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6a5e35b0e1ae7d0008e6be31
😎 Deploy Preview https://deploy-preview-1277--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.

gianfra-t and others added 2 commits July 20, 2026 11:46
[Ready] Fix Alfredpay KYB flow and other miscellaneous changes for dashboard.
@ebma
ebma merged commit f3af6a7 into main Jul 20, 2026
10 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.

3 participants