corridor · integration architecture, phase 3

The seam is
the architecture.

Three providers, one of which doesn't sell this route.

Phase 2 designed the flow. This document specifies what actually sits behind it: the provider audit, the interfaces, the money types, the transfer state machine, and the reconciliation that keeps three systems with three different clocks telling the same story. It is also the document that says plainly which parts of the named stack exist and which don't.

corridor USD → TRY wallet Privy · real on-ramp Bridge · simulated payout local partner · simulated settlement Base Sepolia · real
provider audit

What the stack can actually do

Verified against live vendor documentation in August 2026, not from memory. This table is the reason the rest of the document is shaped the way it is.

ProviderVerified capabilitySelf-serveVerdict
Privy @privy-io/expo + @privy-io/expo-native-extensions. useLoginWithEmail → sendCode/loginWithCode; useEmbeddedEthereumWallet() → getProvider() → EIP-1193. iOS and Android only, no web target. Yes real
Circle USDC Base Sepolia 0x036CbD53842c5426634e7929541eC2318f3dCF7e: FiatTokenV2_2, 6 decimals. Public faucet issues 20 USDC per address per chain every 2 hours. Yes real
Bridge · USD on-ramp ACH, FedNow and Wire are genuinely supported routes. Sandbox keys are not self-serve: a Developer account must be granted by Bridge support before an sk-test key can be generated. No simulated
Bridge · TRY payout Bridge supports USD (ACH/FedNow/Wire), EUR (SEPA), MXN (SPEI), BRL (Pix), GBP (Faster Payments) and COP. TRY is not a supported currency in either direction. This is not a gate; the route does not exist. — unavailable
TRY payout partner The Turkish leg needs a local provider that settles USDC to a Turkish bank account over FAST/EFT. This is how the corridor actually works in-market today. No simulated
KYC provider Document + liveness verification. Sequencing, retry and reason-code handling are designed for real; the vendor response is a fixture. — hybrid

the correction this phase forced

Phases 1 and 2 named Bridge as the TRY payout provider on the strength of the "Stripe's stablecoin stack" framing. That was wrong. Bridge's published route table has no TRY in either direction, so no amount of KYB approval would produce this corridor.

The honest framing is better anyway: Bridge covers the US side, which is its real product. The TRY leg is a corridor-specific partner. A stablecoin remittance app that assumed one vendor covered every destination would be a worse piece of architecture than one that planned for the opposite, because the opposite is what always happens.

the seam

Nothing talks to a provider directly

The RN app has exactly three outbound dependencies: the Privy SDK, a Base RPC for reads, and the Corridor API. It holds no provider credentials, computes no quotes, and cannot tell a mock provider from a real one.

client · react native / expo

11 screens · theme/tokens.ts from phase 1 · component primitives from phase 2. Renders server state, owns no money logic.

Privy access token (JWT)

Privy SDK real

On-device. Auth, embedded wallet, EIP-1193 signing. Key shards never leave the device unencrypted.

Base RPC real

Read-only. balanceOf, receipts, confirmations. Public endpoint, no secret.

⎯ trust boundary ⎯ corridor api (server)

Verifies the Privy JWT against Privy's JWKS on every request. Owns quotes, the ledger, provider credentials, webhook ingress and reconciliation. Every provider secret lives here and only here.

provider interfaces

OnrampProvider mock

bridge.usd.ach
Real product, no sandbox key. Modelled on Bridge's /v0/transfers shape.

PayoutProvider mock

turkiye.try.bank
USDC → TRY over FAST/EFT. Owns IBAN validation.

KycProvider hybrid

kyc.fixture
Real state machine, fixture verdicts.

PayoutProvider unused

bridge.mxn.spei
Never called. Exists to prove the seam is real and not retrofitted.

settlement · base sepolia real

The one leg that genuinely moves value. ERC-20 transfer() on 0x036CbD53…F7e, signed by the Privy embedded wallet. Produces the verifiable tx hash.

Routes are a table, not a codebase

The Bridge gap stops being a defect the moment corridors are data. Adding USD→MXN is a row; it binds to a Bridge payout provider instead of the Turkish one and nothing else changes.

// corridor/routes.ts: the whole multi-corridor story, without building multi-corridor
type CorridorId = 'USD:TRY' | 'USD:MXN';

interface RouteBinding {
  onramp:     ProviderId;   // fiat in
  settlement: ChainId;      // value transport
  payout:     ProviderId;   // fiat out
  limits:     TierLimits;
}

const ROUTES: Record<CorridorId, RouteBinding> = {
  'USD:TRY': {
    onramp:     'bridge.usd.ach',
    settlement: 'base',
    payout:     'turkiye.try.bank',  // ← Bridge has no TRY route
    limits:     TIER_LIMITS.basic,
  },
  'USD:MXN': {
    onramp:     'bridge.usd.ach',
    settlement: 'base',
    payout:     'bridge.mxn.spei',  // ← same vendor, supported route
    limits:     TIER_LIMITS.basic,
  },
};

two corridors differ by one binding · a new destination is a row and a provider, not a release

why this is the load-bearing decision

Every remittance business eventually discovers that no single vendor covers its destination map. The ones that hard-coded a vendor rewrite their settlement layer; the ones that put an interface there add a file. The Bridge TRY gap is the proof that this seam is doing work rather than being decorative. It was discovered during integration, and the design absorbed it without touching a screen.

contracts

Money, quotes, and the interfaces

Money is never a number

Minor units as bigint, currency-tagged, with the scale carried explicitly. The USD→USDC boundary is where the bugs live: USD has 2 decimals, USDC has 6, so every conversion crosses a factor of 10⁴ that a float would quietly corrupt.

// money.ts
type Currency = 'USD' | 'TRY' | 'USDC';

const SCALE: Record<Currency, number> = { USD: 2, TRY: 2, USDC: 6 };

interface Money {
  readonly currency: Currency;
  readonly minor: bigint;    // $500.00 → 50_000n   ·   500.00 USDC → 500_000_000n
}

// Rates are rationals. A float rate is an unreconcilable rate.
interface Rate {
  readonly pair: 'USD/TRY';
  readonly num: bigint;      // 5284n
  readonly den: bigint;      // 100n     → 52.84
  readonly source: 'mid_market';
  readonly observedAt: string;
}

// Rescaling is explicit and total. There is no implicit coercion anywhere.
function rescale(m: Money, to: Currency): Money {
  const d = SCALE[to] - SCALE[m.currency];
  return {
    currency: to,
    minor: d >= 0 ? m.minor * 10n ** BigInt(d)
                  : m.minor / 10n ** BigInt(-d),   // caller proves divisibility
  };
}

Quotes are signed server-side

Screen 03 submits a quoteId, never amounts. The client's numbers are a rendering of the quote, never the source of it, which is what stops a stale countdown or a patched binary from reaching settlement.

// quote.ts
interface Quote {
  readonly id: QuoteId;
  readonly corridor: CorridorId;
  readonly send: Money;          // USD 50_000n            → what the user typed
  readonly receive: Money;       // TRY 2_642_000n         → ₺26,420.00
  readonly totalCharged: Money;  // USD 50_375n            → $503.75
  readonly rate: Rate;
  readonly fees: readonly FeeLine[];
  readonly expiresAt: string;    // server clock, always
  readonly sig: string;          // HMAC over every field above
}

interface FeeLine {
  readonly kind: 'onramp' | 'network' | 'payout';
  readonly label: string;        // 'On-ramp · ACH → USDC'
  readonly amount: Money;        // USD 125n → $1.25
}

fees are named after the rail that consumes them; the breakdown on screen 03 is this array, rendered

The provider interfaces

Three interfaces, each implemented once per rail. validateDestination is where Türkiye's IBAN mod-97 check lives and where Mexico's CLABE check would; destination correctness is a property of the payout rail, not of the app.

// providers/contracts.ts

interface OnrampProvider {
  readonly id: ProviderId;
  supports(rail: FiatRail): boolean;                    // 'ach' | 'wire' | 'card'
  estimateSettlement(rail: FiatRail, at: Date): Date;   // → 'arrives Wed, Aug 5'
  createDeposit(req: DepositRequest, idem: IdempotencyKey): Promise<ProviderRef>;
  parseWebhook(raw: unknown, sig: string): OnrampEvent | null;
}

interface PayoutProvider {
  readonly id: ProviderId;
  supports(corridor: CorridorId): boolean;
  validateDestination(d: Destination): ValidationResult;  // local + synchronous
  submit(req: PayoutRequest, idem: IdempotencyKey): Promise<ProviderRef>;
  parseWebhook(raw: unknown, sig: string): PayoutEvent | null;
}

interface KycProvider {
  readonly id: ProviderId;
  createSession(userId: UserId): Promise<{ sessionId: string; uploadUrl: string }>;
  parseWebhook(raw: unknown, sig: string): KycEvent | null;
}

// Every provider normalises its own vendor payload into Corridor's event vocabulary.
// The orchestrator never sees a vendor-shaped object. This is the entire point:
// swapping turkiye.try.bank for a different partner touches one file.

what parseWebhook buys

Returning null for an unrecognised or badly-signed payload, rather than throwing, makes webhook ingress total. Unknown event types from a vendor's new release get dropped and logged instead of crashing the ingress endpoint and stalling every in-flight transfer behind them. A provider shipping a feature you haven't heard of should never be an outage.

state machine

One transfer, eleven states

State is a fold over an append-only event log, not a mutable column. Three providers with three clocks will deliver events out of order and more than once; a log makes that a non-event, and it means the in-flight screen can always be rebuilt exactly as the user saw it.

draft quoted kyc_pending funding
quote expires →quote_expiredre-quote → quoted
kyc rejected →kyc_resubmit
funded_held chain_pending chain_confirmed payout_pending
ACH returned →clawback
reorg →chain_pending(resubmit, same idempotency key)
delivered payout_returned refunded
// transfer/events.ts: the log. State is fold(events).
type TransferEvent =
  | { t: 'quote_accepted';   quoteId: QuoteId }
  | { t: 'onramp_initiated'; ref: ProviderRef; rail: FiatRail }
  | { t: 'onramp_settled';   amount: Money }
  | { t: 'onramp_returned';  code: AchReturnCode }   // R01 | R08 | R10 …
  | { t: 'minted';           amount: Money }         // USD → USDC, at par
  | { t: 'chain_submitted';  txHash: Hex }
  | { t: 'chain_confirmed';  txHash: Hex; confirmations: number }
  | { t: 'payout_submitted'; ref: ProviderRef }
  | { t: 'payout_delivered'; at: string }
  | { t: 'payout_returned';  reason: PayoutReturnReason }
  | { t: 'refunded';         amount: Money };

// Every event carries these. seq is assigned by Corridor on ingress,
// never taken from the provider, whose ordering guarantees are its own business.
interface Envelope {
  readonly seq: number;
  readonly at: string;          // Corridor's clock
  readonly providerAt?: string;  // the vendor's clock, kept for forensics only
  readonly idem: IdempotencyKey;
}
StateMeansScreenExits
quotedSigned quote held, countdown running03, 08funding · quote_expired
kyc_pendingDocuments with provider, verdict outstanding05, 06funding · kyc_resubmit
fundingACH debit submitted, not yet settled09 step 1funded_held · failed
funded_heldUSD settled. Payout deliberately not yet released.09 step 1 ✓chain_pending · clawback
chain_pendingUSDC transfer submitted to Base09 step 3chain_confirmed · chain_pending (reorg)
payout_pendingWith the Turkish partner, awaiting bank09 step 4delivered · payout_returned
deliveredPartner confirmed credited, not merely submitted10terminal
payout_returnedBank rejected, most often name mismatch11crefunded
clawbackACH returned after value already left—manual
settlement risk

The funded_held decision

Phase 2 asserted that Corridor holds the payout until ACH clears rather than fronting the funds. This is where that becomes a state, a cost, and an admission.

stated plainly

Corridor's ACH exposure is bounded, not eliminated. Anyone operating this for real needs a funded reserve against R10 returns and a written policy for the clawback state. That's a balance-sheet problem wearing an engineering costume, and the honest thing a design document can do is name it and refuse to pretend the state machine solves it.

Idempotency and reconciliation

the real leg

Producing the proof artifact

One genuinely real transaction, end to end, signed by a wallet the user owns. Everything else in this build is a simulation and says so; this is the part that isn't.

// The only place the app itself moves value.
// Privy holds the key shards; the app never sees a private key.

const { wallets } = useEmbeddedEthereumWallet();
const provider = await wallets[0].getProvider();

// ERC-20 transfer(address,uint256) on Base Sepolia USDC
const USDC = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';  // 6 decimals

const txHash = await provider.request({
  method: 'eth_sendTransaction',
  params: [{
    to:   USDC,
    data: encodeFunctionData({
      abi: erc20Abi,
      functionName: 'transfer',
      args: [treasuryAddress, 500_000_000n],   // 500.00 USDC
    }),
  }],
});

// → surfaced on screen 09 the moment it exists, not held back for the receipt.
// → 0x551c···ba10  ·  verifiable on sepolia.basescan.org

unresolved, needs a decision in phase 4

The locked demo data sends 500.00 USDC, but the faucet issues 20 USDC per 2 hours. Either the walkthrough accumulates a balance over about two days, or the real testnet leg moves a smaller amount than the UI's headline figure. The second option would make the tx hash disagree with the receipt, which defeats its purpose as evidence. Recommendation: accumulate, and record the walkthrough once.

mock strategy

The app cannot tell

A mock that the application knows about is a mock that hides integration bugs until the day you swap it. Corridor's simulated providers implement the same interfaces, emit the same events through the same webhook ingress, and are selected by configuration.

// providers/registry.ts
const PROVIDERS: Record<ProviderId, Provider> = {
  'bridge.usd.ach':  env.BRIDGE_KEY ? new BridgeOnramp(env)  : new SimulatedOnramp(fixtures.ach),
  'turkiye.try.bank': env.TRY_KEY    ? new TurkiyePayout(env)  : new SimulatedPayout(fixtures.try),
  'kyc.fixture':     env.KYC_KEY    ? new VendorKyc(env)     : new SimulatedKyc(fixtures.kyc),
};

// There is no `if (isMock)` anywhere above this line. Not in the orchestrator,
// not in the API layer, and certainly not in the app. The seam is the only
// place that knows, and it decides once, at boot, from the environment.
trust boundary

What lives where

AssetLocationRationale
Wallet key shardsDevice + Privy + recoveryNever reconstructed server-side. Corridor cannot move a user's USDC; it can only ask the app to.
Provider API secretsCorridor server onlyA mobile bundle is a public artifact. Anything shipped in it is disclosed, and a leaked Bridge key is a payments incident.
Quote signing keyCorridor server onlyClient-side quoting means client-authored rates.
Session authPrivy JWT, verified against Privy's JWKSCorridor mints no identity of its own, so one fewer credential store to breach.
KYC document imagesClient → provider, directSigned short-lived upload URL. Corridor stores only {sessionId, status, reasonCode}. This is the technical fact behind the promise on screen 05.
Recipient IBANsCorridor server, encryptedDisplayed masked (TR20 ···· 2345); full value never returned to the client after creation.
Base RPCPublic endpointReads only. No secret, no signing.
setup

What has to happen before phase 4

Two of these need an account created by hand. Everything else is code.

Privy app real · self-serve

Create an app at dashboard.privy.io → app ID + client ID. Enable email login and embedded wallets on EVM. Add the Expo scheme to allowed origins. Requires a signup.

Testnet USDC real · self-serve

Fund the wallet from faucet.circle.com on Base Sepolia: 20 USDC per address per 2 hours. Also needs Base Sepolia ETH for gas from a public faucet. Start early: the 500.00 USDC demo figure takes about two days to accumulate.

Bridge sandbox blocked

Requires emailing support@bridge.xyz for a Developer account before an sk-test key can be generated. Worth sending; a reply either way is itself a documentable finding for the writeup. Not on the critical path; the simulated adapter ships regardless.

TRY payout partner simulated

No self-serve option exists for USDC→TRY bank settlement. The adapter is written against a documented partner's API shape and disclosed as simulated. No account needed.

Corridor API code

Quote signing, ledger, webhook ingress, reconciler. Small: the interesting parts are the state fold and the provider registry, not the transport.

Expo app code

@privy-io/expo + native extensions + polyfills. Tokens port from phase 1 unchanged; screens port from phase 2. No web target: Privy's Expo SDK is iOS/Android only, so a web demo is not available as a fallback.

Screen → service map

ScreenCallsProvider
01 WelcomeuseLoginWithEmail · wallet provisioningprivy
02 HomebalanceOf · GET /transfers · rate feedchain rate
03 AmountPOST /quotes → signed Quotecorridor
04 Add USDOnrampProvider.estimateSettlement · createDepositbridge
05-06 KYCKycProvider.createSession · direct uploadfixture
07 RecipientPayoutProvider.validateDestination, local mod-97local
08 ReviewPOST /transfers with quoteId + idem keycorridor
09 In flightEvent stream + backoff poll · chain subscriptionchain fiat
10 ReceiptImmutable settled record · BaseScan linkhash
11 EdgeRe-quote · reasonCode map · return handlercorridor

Carried into the phase 5 failure-modes writeup: Bridge has no TRY route and why the seam absorbed that; ACH R10 exposure being bounded rather than eliminated, and the reserve it implies; rate-lock expiry and re-quote semantics; Turkish name-match rejection as an unpreventable failure made cheap; chain reorg resubmission under a stable idempotency key; KYC provider outage as a hard stop rather than a bypass; the faucet-versus-demo-amount constraint on the proof artifact; and that Privy's Expo SDK has no web target, so there is no browser fallback demo.