End-to-end Brazilian PIX on/off-ramp demo against the Etherfuse sandbox on Stellar testnet.
Runs as a single Node.js script. Every step prints to stdout, and the bundled
EtherfuseClient logs every HTTP request/response with an [Etherfuse]
prefix — so if any step fails, the trail in the terminal is sufficient to
diagnose it without re-running.
- Generates a fresh Stellar testnet keypair
- Funds it via Friendbot
- Creates an Etherfuse customer
- Submits programmatic KYC (identity + placeholder document images + accepts agreements)
- Polls until KYC is approved (the sandbox auto-approves)
- Registers a PIX bank account (CPF key) against the customer's stub bankAccountId — required before either ramp will accept the stub
- Resolves the TESOURO asset's testnet
CODE:ISSUERvia/ramp/assets - Establishes a TESOURO trustline (signs + submits with the local keypair)
- Gets an on-ramp quote (BRL → TESOURO, 100 BRL)
- Creates the on-ramp order
- Calls
simulateFiatReceivedto mark the payment as received - Polls until the on-ramp order is
completed - Verifies the TESOURO balance on Horizon
- Gets an off-ramp quote (TESOURO → BRL, half the balance) and creates the order
- Polls until Etherfuse returns the burn-transaction XDR (deferred signing)
- Signs and submits the burn transaction on Stellar
- Polls until the off-ramp order is
processing(Etherfusefunded— burn confirmed) orcompleted
cp .env.example .env
# Fill in ETHERFUSE_API_KEY (sandbox key from Etherfuse)
pnpm install
pnpm typecheck # must pass with zero errorspnpm devA successful run ends with a === Demo completed successfully === section
listing customerId, publicKey, both orderIds, and the burn transaction
hash.
A failed run ends with a === FAILED on step N === section showing the
AnchorError (or other error) and the failing step number. The last
[Etherfuse] METHOD URL … and [Etherfuse] Response (NNN): … lines above
that point identify the failing API call and Etherfuse's error body.
pnpm transactions pages through POST /ramp/orders (the org-wide list
endpoint scoped to your API key), filters to orders whose raw Etherfuse
status is funded — the shared processing state, after the deposit/burn
is on-chain but before payout — and splits them into on-ramp vs off-ramp:
$ pnpm transactions
Fetching orders from https://api.sand.etherfuse.com/ramp/orders …
Found 15 order(s) total.
=== Processing on-ramp orders (5) ===
• a9c7425b-… status=processing(funded) 100 BRL → ? TESOURO age=18m customer=3ab993c9-…
• 9f9d9c07-… status=processing(funded) 100 BRL → ? TESOURO age=52m customer=3ab993c9-…
• d024e7b8-… status=processing(funded) 100 BRL → ? TESOURO age=45h44m customer=3ab993c9-…
• 4ce5cccb-… status=processing(funded) 100 BRL → ? TESOURO age=45h55m customer=3ab993c9-…
• 8a85fc37-… status=processing(funded) 100 BRL → ? TESOURO age=46h9m customer=3ab993c9-…
=== Processing off-ramp orders (3) ===
• 0a79fbe3-… status=processing(funded) 43.30 TESOURO → ? BRL age=45h2m burn=57dc629308… customer=3ab993c9-…
• 9870c1e0-… status=processing(funded) 43.30 TESOURO → ? BRL age=46h5m burn=75877fccac… customer=3ab993c9-…
• 7901e473-… status=processing(funded) 43.30 TESOURO → ? BRL age=46h16m burn=917cc09da8… customer=3ab993c9-…
All statuses: canceled=1 completed=6 funded=8
Each line shows status=<mapped>(<raw>) (raw funded → shared processing);
when raw and mapped match (e.g. completed) it collapses to one word. The
on-ramp → TESOURO and off-ramp → BRL columns show ? while the order
is still funded — the mint/burn amount only populates once the order
reaches completed.
The Etherfuse sandbox + portable library have a few gaps that this demo papers over. They're worth knowing about if you're integrating the library elsewhere:
-
EtherfuseKycIdentityRequestis missing fields. The starter-pack type only declaresname,dateOfBirth,address,idNumbers. The actual/ramp/customer/{id}/kycendpoint also requiresemail,phoneNumber, andoccupation— without them the customer-agreement step downstream rejects with"Phone number not provided". We pass the extras via a cast insrc/main.ts. -
registerFiatAccountis documented but unimplemented inEtherfuseClient. The README on the starter pack shows the call; theclient.tsdoesn't implement it. We callPOST /ramp/bank-accountdirectly insrc/etherfuse-extras.ts. -
The starter-pack's
EtherfusePixAccountBodydoesn't match the API. It declares{ pixKey, pixKeyType, firstName, lastName, cpf }, but the actual endpoint also requires atransactionId(UUID). We probed shapes against the sandbox and the variant that works is{ transactionId, firstName, lastName, pixKey, pixKeyType, cpf }. PIX is not in the public Etherfuse OpenAPI / docs at all — only Mexican Personal/Business with CLABE — but the sandbox does accept this shape. -
The bank-account registration response doesn't match
EtherfuseBankAccountResponse. It returns{ accountId, label, status, organizationId }. Our extras file declares its ownEtherfusePixRegistrationResponsetype to match. TheaccountIdequals thebankAccountIdwe generated increateCustomer, confirming the call fills in the existing stub rather than creating a new account. -
The bank-account stub is required by
createOnRamp. If you skip step 17 and pass the auto-generatedbankAccountIdfromcreateCustomerstraight tocreateOnRamp, the API rejects with"Proxy account not found". That's why we register the PIX details before either ramp. -
PIX off-ramps don't auto-complete in the Etherfuse sandbox. The docs (
/guides/testing-offramps) explicitly state: "No fiat simulation needed. Unlike onramps, there is nofiat_receivedstep for offramps. The sandbox processes the fiat payout automatically once the burn transaction is confirmed on-chain." In practice this is true for MXN/SPEI but NOT for BRL/PIX. We verified the sandbox sees the burn — the order'sconfirmedTxSignatureis populated and its status movescreated→funded(mapped toprocessingin the shared types) — but it then sits there indefinitely. After 25+ minutes the order'supdatedAtdoesn't change andcompletedAtisnull.pnpm transactionsmakes this directly observable across runs: in the sample output above, three independent off-ramp orders are still infunded45–46 hours after their burns were confirmed on-chain, each with a populatedconfirmedTxSignature. Other things we confirmed are correct (so it isn't a missing-prerequisite issue):- the bank account is
compliant: true,status: "active" - the wallet is registered to the customer with
kycStatus: "approved" - all order fields (
bankAccountId,walletId,confirmedTxSignature,sourceAsset,targetAsset,blockchain) are populated - there is no
/ramp/order/{id}/completeorsimulateFiatSentendpoint in the documented surface (we checked the OpenAPI spec and the/sandbox-api/section) - the customer-facing status page has no admin controls
- claiming wallet ownership via
POST /ramp/customer/{id}/walletwithclaimOwnership: trueis rejected because the wallet is already registered to the parent org The auto-completion the docs describe is implemented for SPEI/Mexico but apparently not yet wired up for PIX/Brazil in sandbox. We acceptprocessing(Etherfuse'sfunded) as the terminal demo state and print a note explaining this.
- the bank account is
-
On-ramp completion time varies, and can hang in
fundedtoo. It usually settles in ~30s aftersimulateFiatReceived, but we've seen it sit inprocessingfor several minutes — andpnpm transactionshas surfaced something stronger: on-ramps that receivedsimulateFiatReceived(so they reachedfunded, not justcreated) and then stayed there for 45+ hours, never advancing tocompleted. In the sample output above that's the three oldest on-ramp rows (d024e7b8…,4ce5cccb…,8a85fc37…). Treat the 10 min on-ramp polling timeout as a best-effort bound, not a guarantee; the sandbox can drop on-ramps in the samefundedlimbo as off-ramps, just less often.
src/
├── main.ts # 20-step orchestrator
├── list-transactions.ts # `pnpm transactions` — list processing orders by ramp
├── logger.ts # step / section / ok / info / fail helpers
├── stellar.ts # Friendbot + sign-and-submit on testnet
├── etherfuse-extras.ts # POST /ramp/bank-account (PIX shape) — gap above
├── mock-identity.ts # Brazilian mock KYC payload
└── lib/ # copied verbatim from regional-starter-pack
├── anchors/
│ ├── types.ts
│ └── etherfuse/{client,types,index}.ts
└── wallet/{types,stellar}.ts