Custody model
The single most consequential architectural decision. Read this before touching anything that moves money.
Model A — Infinia holds user fiat balances
When a user deposits £100 via FPS, that £100 lives in their Infinia GBP virtual account. GrinGo never sees it, never holds it, never has a key that could move it.
Custody sits at Infinia. GrinGo is a stateless orchestrator:
- We call
POST /v1/accounts/internal-transfer/quote/to price an FX move. - We call
POST /v1/accounts/internal-transfer/to execute the FX. - We call
POST /v2/payoutsto push funds out. - We record what happened in our own ledger.
- We never hold a signing key that could authorise any of these.
Why we picked Model A over a wallet-based alternative
The obvious alternative — call it Model B — would keep the user's balance as USDC on Base in a non-custodial Privy embedded wallet, and use Infinia only as an on/off-ramp. That's how the earlier Bridge-based architecture worked.
| Dimension | Model A | Model B |
|---|---|---|
| PoS latency | ~15–20s (single Infinia hop) | ~35s (on-chain leg + Infinia hop) |
| FX events per payment | 1 (GBP → BRL) | 2 (GBP → USDC on deposit, USDC → BRL on payment) |
| On-chain complexity | None | viem, RPC, USDC contract reads, paymaster for sponsored gas |
| Vendor count | 1 (Infinia) + auth + KYC bundled in Infinia HOSTED | 2 (Infinia + wallet vendor) + on-chain infra |
| Custody | Infinia (regulated third party) | User (self-custody via MPC or embedded wallet) |
| Vendor lock-in | High — swapping vendors means migrating customer money | Low — vendor is just a rail |
| Regulatory posture | GrinGo as agent/introducer on top of a custodial partner | GrinGo as non-custodial software |
For a tourist payments app, users care about speed and reliability, not self-custody. Model A wins for MVP on the criteria that actually drive product adoption. Model B's advantages are real but only pay off in scenarios (crypto-native audience, cross-chain features, DeFi integrations) that aren't part of the MVP roadmap.
Model A depends on Infinia being licensed to hold consumer GBP/EUR balances for our target users. That is a prerequisite to confirm before build, not an assumption to live with.
The migration path back to Model B
Model A creates real vendor lock-in. Swapping Infinia for another custodial partner means moving customer money — a 4–6 week engineering effort plus regulatory ceremony. That's normal fintech (Wise, Revolut, Monzo all deal with it when partner banks change), but it's not free.
The nuclear escape hatch — if Infinia's service, pricing, or licensing ever turns bad — isn't "swap to another custodial vendor" but "shift to Model B". Once user balances live in user-controlled wallets, the payments provider is just a rail and becomes trivially swappable.
Realistic total effort: 2–3 months of concentrated engineering + a gradual per-user rollout.
One-way door. Going Model B → back to Model A would mean asking users to hand custody back, which is effectively irreversible in practice. That's fine — the point of migrating is specifically to shed custodial risk.
User-visible impact after migration:
- New "your wallet" UI (address, USDC balance)
- On-chain deposits (funding via bank → Infinia → USDC → wallet)
- Slower PIX (~35s vs ~20s — on-chain leg is added)
- Recovery / seed-phrase concerns (or MPC social recovery, depending on wallet vendor)
- GrinGo's regulatory posture shifts from "orchestrator on top of custodial partner" to "non-custodial software provider"
Design constraints to keep this door open
These cost ~1–2 days spread across the initial build and cut a future migration from "8+ weeks" to "manageable". Every contributor should recognise them.
| Constraint | Why it matters |
|---|---|
users.id is our internal UUID, never derived from an Infinia identifier | Users survive an Infinia disappearance. |
(auth_provider, auth_provider_user_id) is stored and treated as stable | Web3Auth-style MPC wallets derive addresses deterministically from this. Same user → same wallet address forever, no migration UX. |
Balance is computable from the transactions ledger alone | Reconciliation and vendor-swap don't need to interrogate Infinia to know how much a user has. |
| KYC verified identity stored server-side, not just referenced by Infinia's owner id | If we ever swap payments vendor, we can replay identity to the new vendor without re-onboarding users. Trade-off: increases GDPR exposure surface — encrypted at rest via pgcrypto; see privacy for the middle-ground option (minimal fields cached, rest fetched on demand). |
PaymentProvider interface — route handlers never import the Infinia client directly | Adding a wallet-based rail or a second fiat vendor doesn't require route rewrites. |
Normalised ProviderEvent webhook shape — Infinia-specific fields translated at the edge | New vendor = new webhook parser, everything downstream unchanged. |
Normalised transaction status enum (pending / settling / completed / failed), not Infinia's PROCESSING / SETTLING / COMPLETED / … | Vendor-specific vocabulary doesn't leak into the ledger. |
No infinia_* column names outside provider_accounts.provider_account_id | Renames become data migrations, not schema redesigns. |
See payment-providers for how the PaymentProvider interface is structured in code.