Automated recurring stablecoin remittances on Celo.
A sender authorises a remittance once — recipient, amount, cadence, expiry, and a floor rate if anything is being converted. It then runs unattended. No one holds the sender's keys, and no one can move their money outside the envelope they signed: the contract enforces it, not our code.
Today it pays in stablecoins — the recipient receives USDT, USDC or cUSD, the assets MiniPay actually displays. The naira rails (cNGN to a wallet, or through the cNGN API to a Nigerian bank account) are built and tested but switched off in the UI pending the regulatory work described under Open items. Everything about them below still holds; nothing about them is reachable by a sender right now.
It is also a rail two programs can use: an agent can be paid by a mandate a human signed, collect its own payment when it has earned it, and pay other agents for services — see Agent to agent.
Three things distinguish it from a scheduled transfer script:
- The contract is the authority. A backend key triggers runs and can do nothing else — not redirect, not raise an amount, not outlive an expiry.
- It is callable. An agent, an app or a person can pay a cent over x402 to move a payment forward, inside the same envelope.
- It is a verified agent. Registered with Self Agent ID against a real passport, so the thing moving money has a proof-of-human binding rather than being an anonymous key.
The original plan targeted moove.xyz. That does not work, and the reason is structural rather than a matter of timing: Moove's entire public API is two payment-link endpoints, and its stated design principle — repeated across their docs and enforced in their key model — is "no endpoint moves funds." Money in is programmable; money out is not. Moove Ramp is additionally first-person only, so a third party can never trigger a recipient's payout.
Celo is a public blockchain, so there is no permission to ask. cNGN is a
Nigerian-SEC-regulated naira stablecoin that deployed to Celo in August 2026,
and its API does the thing Moove's cannot: redeemAsset burns cNGN and pays
naira to an arbitrary bank account.
Everything below was verified on-chain and against live docs, most recently 2026-09-22.
| RemessoExecutorV4 | 0x288b7cDD10e069eA64D4984c3E5fa0D9c5816009 — live and in use, verified, unaudited |
| RemessoExecutorV3 | 0xd2e68acd875fb1b3a98dc0d72659910b05e7e08f — superseded 2026-09-22; its schedules remain on-chain and cannot run again |
| RemessoExecutorV2 | 0x218414aD37206fd4cFD6C47947574708DB0e95D2 — superseded |
| RemessoExecutor V1 | 0xC7eF75fC6283aB3b810fa4dE270F074C47761189 — retired, has known defects |
| ERC-8004 agent | 9867, registry 0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 — metadata is a data: URI in the token, so it cannot be changed after the fact |
| Self Agent ID | token 191, agent 0x5C3EBb0084233156ba51a5C2dfD42d88d5a74CA6, registry 0xaC3DF9ABf80d0F5c020C06B04Cced27763355944 |
| cNGN (Celo mainnet) | 0xF6829D7393dAe24509eb1E52eE8e572e2E271a4f — 6 decimals |
| USDT / USDC / cUSD | 0x48065fbbe…483d5e 6dp · 0xcebA9300f…C6f33A8B32118C 6dp · 0x765DE8168…8B1282a 18dp |
| Uniswap SwapRouter02 | 0x5615CDAb10dc425a742d643d949a7F474C01abc4 |
| cNGN/USDT pool | 0x6519d56eb0a69fc0338657784c783b169b8f7d32 — 0.01% tier |
Schedules do not migrate. They live in a contract's own storage, so every
V3 schedule is still there, still owned by its sender, and will never run
again: the executor asks due_schedules for rows authorised against the
contract it is configured with, and ids collide across deployments. A sender
re-approves the new address and re-creates; the app says so on any schedule
left behind. See V4.
Decimals. cNGN is 6dp. Mento's unrelated
NGNmis 18dp. Confusing them is a factor of 10¹².
Sender wallet Supabase Celo
───────────── ──────── ────
approve(token) ──┐
createSchedule() ┴──▶ schedules ◀── pg_cron (every minute)
│
▼
execute-due-runs ──▶ runnability() ┌─ Direct: forward
│ ──▶ quote + liquidity │ the funding asset
│ ──▶ executeRun ─────────┤
│ └─ Swap: USDT → cNGN
▼ (off in the UI)
runs ──▶ history ◀── Goldsky subgraph
(seconds, not polls)
An agent ──▶ POST /trigger-run ──▶ 402 ──▶ signed USDC ──▶ runNow() [V4]
▲
x402 facilitator settles, pays gas
Everything on the money path is deterministic: pg_cron decides when,
runnability() decides whether, and the contract decides what. The parts
that are not deterministic — the assistant, the identity check — sit at the
edges and cannot move a cent.
The sender signs one on-chain transaction, not a session key. MiniPay — the
wallet most of this audience actually uses — does not support message signing at
all, so an off-chain permission grant was never viable. An approve plus a
createSchedule call is also better: it is explicit, auditable, and revocable
by the sender without our cooperation.
What the backend's hot key can do: call executeRun(id, minOut).
What it cannot do, enforced in the contract rather than in our code:
- send to any address except the one the sender fixed at creation
- move more than
amountIn, or run more often thaninterval - accept a rate below the sender's
minRateE6floor - run after
expiresAtor pastmaxRuns - touch a paused or cancelled schedule
The sender's ERC-20 allowance is the outermost cap. Revoking it stops everything, immediately, with no involvement from us.
redeemAsset returns a deposit address; cNGN is then sent there and naira is
paid out. So an ngn_bank run goes pending → swapping → redeeming → paid_out,
and only the redemption.completed webhook advances the last step. A run whose
swap has settled has not delivered cash — showing a sender "settled" at that
point is the failure mode this schema is shaped to prevent.
Deployed 2026-09-22, unaudited, and not yet carrying any schedule. Three additions, each pinned into the schedule at consent so no later owner action can reach a remittance somebody already signed:
- A payout asset per schedule, from an owner allowlist. The swap rails were hardwired to cNGN at deployment, which made every new corridor a redeploy.
runNow— an early send, callable by the sender or by an address they nominate, bounded by a number of early sends they authorise, a 60-second gap, and every limit they signed. Default is zero: a V4 schedule behaves exactly like a V3 one unless its sender opts in.- A commission, in basis points, capped at
MAX_FEE_BPS = 50in code. The floor is measured against the net amount, because a floor is a rate — charging it on the gross would tighten the sender's floor by the fee.
54 tests across the four contract suites. Most of the V4 ones are the same shape twice: the owner changes a setting, and a schedule already authorised does not move.
trigger-run sells one early send over x402. An agent POSTs a schedule id,
gets a 402 carrying the exact price, retries with a signed USDC authorisation,
and one run fires ahead of its cadence. No account and no API key — the payment
is the authentication, which is why verify_jwt = false on that function.
The caller buys timing and nothing else. Destination, amount, asset, floor,
run cap and expiry were fixed when the sender signed, and runNow re-checks
every one on-chain. A schedule whose sender never nominated our executor is
refused with 403 before any price is quoted — nobody should pay to discover
that.
Order is verify → run → settle: a run that reverts charges nobody, and a settlement that fails after a successful run is logged as our loss rather than retried against a caller who already got what they paid for.
Proven on mainnet 2026-09-22: one request moved 0.01 USDC to the treasury and
0.1 USDT to a recipient
(run,
settlement).
scripts/x402-buy.ts is the buyer half, if you want to try it.
The facilitator advertises x402 v1 under network
celoand v2 undereip155:42220. Pairing a version with the other name is rejected asunsupported_scheme, which reads like a scheme problem and is a naming one.
Remesso is a remittance rail two programs can use without a person in the loop at the moment money moves — and without either program being trusted.
A payer signs a mandate. Who is paid, how much, how often, until when, and how many payments a named counterparty may collect early. That signature is one on-chain transaction and it is the whole of the authority anyone gets.
The payee collects. It calls runNow with its own key and its own gas, and
takes exactly one payment — the amount the payer fixed, to the address the payer
fixed, at most the number of times granted, never twice inside a minute, never
past the expiry. The payer can withdraw the grant at any moment, and revoking
the token allowance stops everything without our cooperation.
Proven on Celo mainnet, 2026-09-28. Agent A created a schedule paying agent B
0.02 USDT with the first payment an hour away, granting one early collection.
Agent B then collected while the schedule was not due — runnability said
due: false, triggerability said yes — and received 0.01995 USDT, the
commission taken at the rate pinned when A signed. Its second attempt was
refused: the grant was spent.
For an agent with no CELO for gas, trigger-run does the same thing for a cent
in USDC, and the facilitator pays the gas.
The same wallet can buy. _shared/x402-pay.ts reads a 402, checks the price
against a per-call ceiling and a rolling daily cap, signs a USDC authorisation
and retries — every payment written to the agent_spend ledger before it is
signed. rate-service is the other end of that: live NGN rates for a tenth of
a cent, so the loop can be exercised in both directions.
Exercised the same day: Remesso's agent paid 0.001 USDC for a rate quote
(settled 0x53d4fdb3…), and one of its schedules paid out on demand
(0x630e4681…). No API key, no account, no prior relationship on either side.
Connecting one takes nothing but HTTP: docs/AGENTS.md is the page to hand someone pointing an agent at this.
| To be paid | To pay |
|---|---|
| An address. That is all — the payer names it. | USDC on Celo. No CELO: EIP-3009 means the facilitator pays gas. |
To collect early: a grant, plus gas — or a cent for trigger-run. |
An x402 client. scripts/agent-pay.ts is ~40 lines. |
| Nothing to install, no key shared with us. | GET /functions/v1/agent to discover; the 402 is the quote. |
Per-minute granularity, not per-call streaming: MIN_TRIGGER_GAP is 60
seconds. And gas is the real floor — measured on our own transactions, not
taken from a published figure: a payment costs ~91,875 gas, which at 202 gwei
and CELO near $0.09 is about $0.0017, and creating a schedule is 224,000
gas ($0.004). So a one-cent payment loses roughly 17% to gas, and a payment of
a few cents is the honest floor. Sub-cent streaming is payment channels, and
none of this is that.
Two separate things, both optional to the money path:
- Senders can verify with Self from the home screen: a
Pre-KYC flow (OFAC screening, 18+) proved from a passport, zero-knowledge, no
personal data stored. It gates nothing — that was a decision, not an
omission. Making it a gate means a check in
execute-due-runs. - Remesso itself is registered as a Self Agent ID (token
191), soulbound to the cold owner wallet. That is what makes the agent sybil-resistant rather than an anonymous key, and it is the Work-tier requirement for Celo's Agent Visa.
MiniPay cannot open the Self app directly — its webview blocks the handoff and falls through to the App Store, losing the request. The app therefore asks the sender to copy the link into Chrome or Safari. Verified on device.
Two providers, neither of which can move money.
DeepSeek turns a sentence into a draft schedule the sender edits and signs. TypeSafe (Jev) answers typed questions — a label and a probability, never prose — and does three jobs: classifying a failure reason, flagging fields a draft read ambiguously, and scoring how unusual a schedule is before it is signed.
The rule that matters: no model writes the words a sender reads about a
payment. classify_run returns one of our categories and web/lib/failures.ts
holds the sentence. Reasons the backend writes itself are matched by regex
first; Jev is asked only about the tail, and only above 0.6 confidence.
Every transaction Remesso causes carries the Celo attribution suffix
(ERC-8021, code remesso) — the executor's runs and each wallet write a sender
signs. Celo's reward distribution reads this data and untagged transactions
cannot be claimed retroactively. First tagged transaction: 2026-09-22.
A Goldsky instant subgraph over the executor gives the UI a run within seconds
of its block, instead of waiting for the backend write plus a 15-second poll.
It is advisory: web/lib/history.ts merges it with the runs table, and
without it the history is simply the database. See indexer/README.md.
contracts/ Foundry. V1-V4 + 54 tests.
indexer/ Goldsky instant subgraph over the executor (see its README).
scripts/
check-env.ts Is .env complete and coherent? Proves the SSH key.
x402-buy.ts The buyer half of trigger-run: quote, sign, pay, call.
e2e-agent-test.ts Local end-to-end run against a funded test wallet.
supabase/
migrations/ Schema, RLS, pg_cron triggers.
functions/
_shared/ Config, cNGN client, Celo/viem, Self, Jev, x402,
attribution, failure classification — with tests.
execute-due-runs/ The executor loop.
trigger-run/ Paid early send over x402 (no JWT; payment is auth).
self-verify/ Starts an identity check, reports where it stands.
self-webhook/ Self results (Svix-signed; retries, unlike cNGN).
ai-assist/ Draft a schedule; classify a failure; score a risk.
cngn-webhook/ Redemption settlement (fast path).
reconcile-redemptions/ Redemption settlement (authoritative path).
cngn-proxy/ The frontend's only route to the cNGN API.
balance-poller/ Funding + liquidity pre-flight warnings.
web/ Next.js sender app. MiniPay-first, wagmi + viem.
app/api/ Server-only handlers; forward to the Edge Functions.
lib/ Config, ABI, hooks, formatting, failure copy, allowance
maths, attribution, subgraph reads.
cp .env.example .env # fill in secrets; never commit this
cd contracts
forge install # forge-std + openzeppelin
forge test # 54 passing across V1-V4
# Deploy to testnet FIRST and run a full schedule end to end there.
DEPLOYER_PRIVATE_KEY=0x... EXECUTOR_ADDRESS=0x... \
forge script script/DeployV4.s.sol:DeployV4 --rpc-url celo_sepolia --broadcast
cd ..
supabase db push
supabase functions deploy execute-due-runs cngn-webhook reconcile-redemptions \
cngn-proxy balance-poller ai-assist self-verify self-webhook trigger-run
supabase secrets set --env-file .env.functions.env.functions, not .env: Supabase injects SUPABASE_URL, SUPABASE_ANON_KEY
and SUPABASE_SERVICE_ROLE_KEY itself and rejects them as reserved, failing the
whole command. TESTING_* is excluded too — those hold a funded wallet key used
only by the local e2e script, and shipping a spendable key into a deployed
environment for no reason is how blast radius grows quietly.
grep -vE '^(SUPABASE_(URL|ANON_KEY|SERVICE_ROLE_KEY)|TESTING_)' .env \
| grep -E '^[A-Z0-9_]+=' > .env.functionsEverything optional degrades to silence rather than an error. Without
DEEPSEEK_API_KEY there is no draft-from-a-sentence; without
TYPESAFE_API_KEY failures are still classified by rules; without SELF_*
the verify card never appears; without X402_* paid triggering answers 503;
without NEXT_PUBLIC_GOLDSKY_SUBGRAPH_URL the history is the database alone.
scripts/check-env.ts says which of these you have.
Set the cron vault secrets once, so the service-role key never sits in a cron definition in plain text:
select vault.create_secret('https://<ref>.supabase.co', 'project_url');
select vault.create_secret('<service-role-key>', 'service_role_key');Point the cNGN dashboard's webhook URL at
https://<ref>.supabase.co/functions/v1/cngn-webhook and set the signing
secret to CNGN_WEBHOOK_SECRET. Configure the test URL and verify against
sandbox transactions before touching the live one.
Enable anonymous sign-in before the frontend will work at all: Supabase
Dashboard -> Authentication -> Sign In / Providers -> Anonymous sign-ins. It is
off by default, and ensureSender() calls signInAnonymously() on every wallet
connection — MiniPay cannot sign messages, so SIWE is not available to this
audience. Without it every request fails with
422 anonymous_provider_disabled, which surfaces as a frontend that silently
shows no schedules.
Then the sender app:
cd web
cp .env.local.example .env.local # public config only; no cNGN secrets here
npm install
npm run devweb/.env.local holds nothing sensitive by design. The cNGN API key, AES key
and Ed25519 private key exist only as Supabase secrets, reachable through the
cngn-proxy Edge Function — the docs are explicit that they must never reach
client-side code, and cNGN whitelists by IP, so one egress point is also the
only arrangement that can be whitelisted at all.
There is exactly one cNGN venue on Celo — the Uniswap V3 pool above. It holds roughly 118M cNGN and 95k USDT. Measured via QuoterV2 on 2026-09-07:
| Trade | Slippage |
|---|---|
| $100 | 0.005% |
| $1,000 | 0.055% |
| $2,500 | 0.14% |
| $5,000 | 0.27% |
| $10,000 | 0.55% |
Re-measured via QuoterV2 on 2026-09-14: the pool held 93,611 USDT and 119.6M
cNGN, and spot was ₦1,369.28/USDT. liquidityIsHealthy aborts above 150bps,
which this pool does not reach until roughly $27–30k.
Spot was ₦1,368/USDT, sitting sensibly between the official NFEM rate (₦1,329) and the parallel market (₦1,395–1,410).
For remittance-sized transfers this is effectively free. Two consequences are baked into the code:
LIMITS.maxRunAmountUsdtcaps a single run well below the point where slippage bites, andliquidityIsHealthy()aborts a run the pool cannot fill cheaply rather than executing it at any price.- About 76% of all cNGN on Celo sits in that one pool. One LP withdrawal
removes the entire swap route. Treat the swap as a replaceable component:
Textile FX (OTC, 78 onboarded Nigerian firms) is the venue for size, and
minting cNGN directly through ASC is the endgame. Do not let the Uniswap call
leak beyond
_shared/celo.ts.
Implemented against the published spec at docs.cngn.co, not against
assumptions. Four properties of that API drive the code:
Requests are encrypted, responses are sealed. POST and PUT bodies go out as
{content, iv} — AES-256-CBC under a key derived by SHA-256'ing the dashboard
encryption key. Every successful response comes back as
{status, message, data} where data is a base64 libsodium crypto_box
sealed to your Ed25519 public key: nonce(24) || ciphertext || ephemeral pk(32).
It is an opaque string on the wire, so reading a field off it without opening it
first silently yields undefined.
supabase/functions/_shared/cngn.test.ts verifies both directions against the
docs' own reference implementations — our AES ciphertext is byte-identical to
what Node's createCipheriv produces, and the tests play the role of the cNGN
server, sealing payloads to a freshly generated ssh-keygen key and requiring
the client to open them. Run it with:
deno test --allow-env --allow-read --allow-write --allow-run --allow-net \
supabase/functions/_shared/cngn.test.tsThe rate limit is 20 requests per 60 seconds per API key, and breaching it
blocks the key for another 60 seconds. That makes the limiter in cngn.ts a
correctness feature rather than politeness: one burst of bank-list lookups from
a signup page would otherwise strand an in-flight redemption. Banks and networks
are cached for six hours in the Edge Function and again for twelve in the Next
route handler, which is what the docs advise.
Webhooks are delivered exactly once. "Deliveries are sent once, with a
10-second timeout, and are not automatically retried." A 500 from us — a cold
start, a deploy landing mid-delivery — loses the event permanently and leaves a
run stuck in redeeming while the recipient has in fact been paid. So
cngn-webhook persists and acknowledges before doing any work, and
reconcile-redemptions polls /transactions every ten minutes for anything
still open. The webhook is the fast path; the transactions API is the authority.
Only four endpoints are on the money path, and they are deliberately split by who can reach them:
| Reachable from | Notes | |
|---|---|---|
GET /banks |
cngn-proxy (signed-in senders) |
Cached hard; ~900 rows |
POST /account/verify |
cngn-proxy (signed-in senders) |
At onboarding only, never at payout |
POST /redeemAsset |
execute-due-runs only |
Requires the "Redeem" permission |
GET /transactions |
reconcile-redemptions only |
Settlement of record |
Note the path is /account/verify, not /verifyAccountDetails — an earlier
draft of this repo had the latter, which does not exist.
Everything below comes from the merchant dashboard, and each environment has
its own complete set. A cngn_test_ key with a live encryption key fails; so
does a live key against the SSH key you uploaded for test.
-
Onboard as a merchant. Sandbox access needs an account. Live keys are issued only after KYB completes: business documents, identity verification for account owners, and the onboarding fee. This gates the entire live path, so start it early — it is not a same-day step.
-
API key and encryption key. Dashboard → Settings → API Key. Both are already generated; reveal with the eye icon and copy with the clipboard icon. The prefix picks the environment (
cngn_test_…/cngn_live_…); the base URL is the same either way. →CNGN_API_KEY,CNGN_ENCRYPTION_KEY -
Ed25519 keypair — you generate this, not cNGN.
ssh-keygen -t ed25519 -C "api@remesso" -f cngn_api_keyPaste
cngn_api_key.pubinto the SSH public key field on the same tab. Keep the private half. Without it every request fails withNo Test SSH Key found, and nothing the API returns is readable. →CNGN_SSH_PRIVATE_KEY(the private half; escaped\nis fine)Take the default of no passphrase. cNGN's guide offers one, but the executor runs unattended and has nothing to prompt with — a passphrase encrypts the key body and the private key cannot be loaded at all. If you already set one, strip it; the public key is unchanged, so the dashboard needs no update:
ssh-keygen -p -N "" -f cngn_api_keyThe four ways this goes wrong — passphrase, the
.pubfile pasted instead of the private half, an RSA key, and a truncated paste — each fail with a message naming the actual problem rather than a generic parse error. They are covered incngn.test.ts, because the failure they'd otherwise produce is an unexplained key mismatch hours later. -
Permissions. An organisation admin grants these per key. Remesso needs Redeem. Without it
redeemAssetreturns{"status": false, "message": "Permission denied"}— note that shape: the HTTP status is not the whole story, which is why the client checks both. -
IP whitelist. Dashboard security settings. See below — this is the part that does not have a clean answer yet.
-
Webhook signing secret. Dashboard → webhooks. Set the URL to
https://<ref>.supabase.co/functions/v1/cngn-webhookand subscribe toredemption.completedandtransaction.failedat minimum. →CNGN_WEBHOOK_SECRET
# Prove the credentials before wiring anything up. /balance needs no request
# encryption, so a 200 here means the key, the IP and the SSH key are all good.
curl -s -X GET "https://api.cngn.co/v1/api/balance" \
-H "Authorization: Bearer $CNGN_API_KEY"The data field of that response will be an unreadable base64 string. That is
correct — it is sealed to your public key.
cNGN checks the source IP of every request against a dashboard whitelist and
answers 403 IP address not whitelisted regardless of credentials. Supabase
Edge Functions run on a large, dynamic pool of addresses, so there is nothing
stable to whitelist. This is a deployment blocker for the live environment,
not a code problem, and it has three possible answers:
cNGN confirmed 2026-09-10: static IP only, no CIDR. That settles it — there is no arrangement under which Supabase's rotating egress can be whitelisted, so cNGN traffic has to leave from one fixed address.
- A fixed-IP forward proxy — the recommended path. Set
CNGN_EGRESS_PROXY_URLandcngn.tsroutes every cNGN call through it, throwing a clear error rather than silently going direct if the runtime cannot. Verified 2026-09-10 that the Supabase Edge Runtime (supabase-edge-runtime-1.76.0, Deno 2.1.4) does exposeDeno.createHttpClientand accepts a proxy config, so this works without moving anything. A small VPS running tinyproxy, a Fly.io machine with a dedicated IPv4, or a managed static-IP proxy all satisfy it. - Move the cNGN-touching functions onto a host that has a static egress IP.
The 403 was reproduced directly: GET /balance with the live key returns
{"status":403,"message":"IP address not whitelisted"}, and an empty IP Access
List blocks everything rather than allowing everything.
- Regulatory posture — unresolved, and the reason the naira rails are off.
redeemAssetputs Remesso in the business of instructing naira payouts to third parties. That is a money-transmission question and it needs a Nigerian fintech lawyer before it goes near real senders. Until then the product ships stablecoin-only (NEXT_PUBLIC_ENABLE_CNGN_RAILSis not"1"), which needs no payout partner, no cNGN API and no licence answer. Nothing is deleted: the contract keeps all three rails and existing schedules of any kind still render. - V4 is unaudited. It is deployed and was exercised once on mainnet, but no audit has been run, and it holds spending authority the moment a sender approves it. Audit before migrating anyone.
- The V3 → V4 migration is half done. The app and the executor are on V4 as of 2026-09-22; the schedules authorised against V3 are not, and cannot be — each sender must approve the new address and re-create. No V4 schedule has been created through the app yet.
BANK PAYOUTS CANNOT WORK ON THE DEPLOYED CONTRACT.Fixed in V2 and deployed in V3. The original defect, found in the 2026-09-14 review: cNGN only burns whenisExternalSenderWhitelisted(msg.sender), and a Uniswap swap makes the pool the transferor, so a bank run would debit the sender, emitRunExecuted, pass both slippage guards and never pay naira. V2 onwards swaps to itself and forwards with an explicitsafeTransfer, making one whitelistable address the sender-of-record. cNGN must still whitelist the live executor address before a transfer from it will burn — and a new deployment is a new sender-of-record, so V4 needs its own whitelisting.- Redemption-address stability — unanswered by cNGN.
redeemAssetreturns a deposit address per redemption, while the contract fixesdestinationat authorisation. Two guards exist:execute-due-runscompares the address cNGN returns against the on-chain destination and aborts before spending gas if they differ, and the UI hides bank payouts entirely. If it does rotate, the envelope needs a sender-approved allowlist rather than one address. - cNGN account is not verified. ₦100,000 plus KYB, and
redeemAssetdoes not work until it completes. - The IP whitelist needs a fixed-IP proxy standing up. cNGN will not
whitelist a CIDR, and Supabase egresses from the whole AWS
eu-central-1pool — measured: 8 consecutive calls, 8 distinct IPs. - Sender identity is a claim, not a proof. MiniPay does not support message
signing, so SIWE is unavailable and the app binds a wallet to an anonymous
Supabase session. This does not weaken the money path — the contract checks
msg.sender, and nothing in the database can move funds — butsenders.wallet_addressis first-come, so an address can be squatted. Self verification proves a human, not wallet ownership; it does not close this. - The
remessoattribution code is not yet credited on Celo's dashboard. Tagging works regardless; crediting is a registry step with the Celo team. - Recipient notifications — one seam left in
balance-poller.