Currency Exchange
Implement atomic currency conversion, explicit fees, liquidity controls, quote replay, reconciliation, and FX exposure monitoring on Parix.
Overview
Use Parix as the value-movement ledger behind a currency exchange service when one accepted quote must debit the source currency, collect any explicit fee, and credit the destination currency exactly once.
This guide converts 10000 USD cents into 9150 EUR cents and charges a separate 50 USD-cent service fee. The conversion is one linked TigerBeetle chain:
| Leg | Debit | Credit | Amount | Ledger |
|---|---|---|---|---|
| Source principal | Customer USD | USD FX pool | 10000 | 840 |
| Service fee | Customer USD | USD fee revenue | 50 | 840 |
| Destination payout | EUR FX pool | Customer EUR | 9150 | 978 |
The first two transfers carry the linked flag and the final transfer closes the chain. Either all three legs commit or none does.
Parix records the amounts selected by the quote service; it does not calculate market rates, spreads, rounding, sanctions decisions, or hedge orders. Keep the accepted quote and its pricing inputs in the application database.
Architecture and ownership
| Component | Owns | Does not own |
|---|---|---|
| Exchange application | Customer workflow, quote lifecycle, rate source, spread, rounding, limits, stable IDs, and accepted quote | Authoritative posted ledger balances |
| Parix | Currency accounts, immutable transfers, balance constraints, and linked-chain atomicity | Rate discovery, quote expiry policy, KYC, sanctions, or hedging |
| Market-data provider | Tradable or indicative rates, timestamps, and source provenance | Customer balances or conversion settlement |
| Liquidity and treasury system | Pool funding, exposure limits, hedge orders, and external cash settlement | Customer quote acceptance |
| Reconciliation | Matching quotes, Parix transfers, pool positions, bank statements, and hedge fills | Silent mutation of committed transfers |
Use a durable workflow or outbox between these systems. A Parix commit, a bank movement, and an external hedge are not one distributed transaction. The FX pools absorb the ledger exposure until treasury settles or hedges it.
Ledger model
Ledgers
Use one ledger for each currency and one integer unit for that ledger. This example uses ISO 4217 numeric currency codes as ledger IDs, but that convention is an application decision.
| Ledger | Unit | Purpose |
|---|---|---|
840 | USD cents | Customer USD balances, USD liquidity, and USD fees |
978 | EUR cents | Customer EUR balances and EUR liquidity |
Every individual transfer connects accounts in the same ledger. The linked batch may contain transfers from different ledgers, which makes the complete conversion atomic without pretending that USD cents and EUR cents are the same unit.
On a shared Developer database, each previously unseen external ledger can allocate a project-ledger mapping and consume ledger quota. Reserve the planned currency ledgers deliberately and use a dedicated production plan for production traffic.
Accounts
Credit-normal customer and liquidity accounts use history (8) plus debits_must_not_exceed_credits (2), for flags 10. This prevents customers and funded pools from spending more posted and pending value than they hold.
| Role | Example ID | Ledger | Code | Flags | Balance meaning |
|---|---|---|---|---|---|
| USD treasury source | 870000000000000001 | 840 | 100 | 8 | Reconciled source for controlled USD test funding |
| Customer USD | 870000000000000002 | 840 | 110 | 10 | Customer spendable USD cents |
| USD FX pool | 870000000000000003 | 840 | 200 | 10 | USD liquidity received and paid by conversions |
| USD fee revenue | 870000000000000004 | 840 | 210 | 8 | Explicit exchange service fees |
| EUR treasury source | 870000000000000005 | 978 | 100 | 8 | Reconciled source for controlled EUR pool funding |
| EUR FX pool | 870000000000000006 | 978 | 200 | 10 | EUR liquidity received and paid by conversions |
| Customer EUR | 870000000000000007 | 978 | 110 | 10 | Customer spendable EUR cents |
The unconstrained treasury sources are modeling boundaries, not permission to invent external money. Gate their use behind a treasury service and reconcile every funding transfer to a bank, settlement, or approved test-funding record.
For production, create one customer account per customer and currency. Store the customer-to-account mapping outside TigerBeetle and avoid placing personally identifiable information in IDs or user-data fields.
Transfer codes
| Code | Event | Debit | Credit |
|---|---|---|---|
| 10 | Controlled pool funding | Treasury | Customer or pool |
| 300 | Exchange source principal | Customer source | Source FX pool |
| 301 | Exchange service fee | Customer source | Fee revenue |
| 302 | Exchange destination payout | Destination FX pool | Customer destination |
| 310 | Customer conversion reversal | Original recipients | Original sources |
| 320 | Reviewed liquidity adjustment | Excess position | Deficient position |
Use different transfer IDs for every leg. Store one quote or conversion ID in user_data_128 on every leg so reconciliation can group the chain after execution; the linked relationship itself is not retained as a queryable grouping.
Quote and rounding policy
The application must turn a rate into integer source, destination, and fee amounts before it submits the chain. Never use binary floating-point math for financial amounts.
For example, represent 0.915000 EUR per USD with a scale of 1_000_000:
destination_minor = floor(source_minor × 915000 ÷ 1000000)Define which side receives any remainder, when the spread is applied, whether the fee is inclusive or additional, and the minimum/maximum trade sizes. Persist the inputs, outputs, rounding rule, rate source, expiry, and accepted-at time with the quote.
Quote expiry is an application admission rule. Check it before durably accepting the quote. Once accepted, retries must use the original amounts and stable IDs even if the market rate or wall clock changes.
Invariants
| Invariant | Enforcement |
|---|---|
| One currency never changes unit | Put every currency in a distinct ledger and use integer minor units |
| A conversion is all-or-nothing | Submit every principal, fee, and payout leg in one ordered batch; set linked on every non-final leg |
| A customer cannot overspend | Apply debits_must_not_exceed_credits to every customer currency account |
| A pool cannot pay unavailable funds | Apply the same constraint to funded liquidity pools and reject/reprice when liquidity is insufficient |
| An accepted quote is immutable | Persist exact amounts, rate metadata, and stable transfer IDs before the first write; never rewrite them during a retry |
| Duplicate delivery cannot duplicate value | Lookup and reuse the same transfer IDs and exact immutable fields |
| Rates remain explainable | Keep rate source, scale, spread, fee, rounding, and acceptance evidence in the application database |
| Exposure remains visible | Reconcile each pool by currency against quotes, external settlement, and hedge records |
Before you begin
You need:
- a Parix database in Ready state and its immutable database UUID;
- approved currency ledger IDs, minor units, account codes, transfer codes, and balance convention;
- a fixed-point quote calculation with an explicit rounding and expiry policy;
- funded source and destination liquidity accounts;
- a durable quote record containing the three transfer IDs before the first Parix write;
- an OAuth session for CLI work and a specific-database API key for the server-side service; and
- reconciliation ownership for customer, pool, bank, market-data, and hedge records.
Use Developer only for learning and integration tests. Use Dedicated Single Node for isolated non-production workloads. Production traffic belongs on Production HA, Production 6, or a contract-defined Enterprise topology.
The public request schema accepts at most 8,190 records in an account, transfer, or lookup array. The selected plan can impose a lower events-per-request limit. Keep the entire linked conversion chain in one request and below the effective plan limit.
Dashboard walkthrough
- Open the intended organization and database. Confirm Ready, plan, database UUID, and effective event quotas.
- Select Connect and generate a Specific database API key for the exchange service. Store the one-time secret in a server-side secret manager.
- Open Query and choose Query accounts.
- Query ledger
840, then ledger978, with a bounded limit. Shared queries require the ledger filter, and an unseen external ledger can allocate a namespace mapping. - Confirm that customer and pool account codes match the application mapping before sending a conversion.
- Use Create accounts or Create transfers only in the intended development database and only with reviewed stable IDs.

Query each currency separately. A zero-row result can mean the ledger has not been initialized; it does not prove that a different currency ledger is empty.
Live-write warning: Query can execute
create_accountsandcreate_transfers. Those operations write immediately. Verify the selected environment, database UUID, currency ledger, account IDs, transfer IDs, amounts, codes, and linked-flag order before selecting Run.
CLI walkthrough
The commands below use the latest published @parix/cli package. The CLI uses browser OAuth and the active organization, so it is for developer and operator work rather than unattended production traffic.
npm install --global @parix/cli@latest
parix --version
parix auth login
parix auth status
parix database list --json
export PARIX_DATABASE_ID="db_replace_with_uuid"Confirm that parix --version reports the installed package version. The CLI defaults to https://parix.io; use --base-url only when deliberately targeting another environment, because sessions and database IDs are environment-specific.
Create fx-accounts.json as a bare JSON array.
[
{ "id": "870000000000000001", "ledger": 840, "code": 100, "flags": 8 },
{ "id": "870000000000000002", "ledger": 840, "code": 110, "flags": 10 },
{ "id": "870000000000000003", "ledger": 840, "code": 200, "flags": 10 },
{ "id": "870000000000000004", "ledger": 840, "code": 210, "flags": 8 },
{ "id": "870000000000000005", "ledger": 978, "code": 100, "flags": 8 },
{ "id": "870000000000000006", "ledger": 978, "code": 200, "flags": 10 },
{ "id": "870000000000000007", "ledger": 978, "code": 110, "flags": 10 }
]Submit the accounts and inspect the full response:
parix tb create-accounts "$PARIX_DATABASE_ID" --file ./fx-accounts.json --jsonIn this isolated example, fund the customer USD account with 100050 cents and the EUR pool with 500000 cents. Production funding must be driven by reconciled settlement events rather than ad hoc CLI commands.
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 880000000000000001 \
--from 870000000000000001 \
--to 870000000000000002 \
--amount 100050 \
--ledger 840 \
--code 10 \
--json
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 880000000000000002 \
--from 870000000000000005 \
--to 870000000000000006 \
--amount 500000 \
--ledger 978 \
--code 10 \
--jsonCreate accepted-fx-quote.json. Flags 1, 1, and 0 create one closed linked chain. Optional user_data_* groups the legs for later query (TigerBeetle indexes these fields; only non-zero values filter):
user_data_128— accepted quote ID (the “what” that ties the multi-ledger legs)user_data_64— scaled rate snapshot used at acceptance (application-defined)user_data_32— quote-convention revision (application-defined)
Keep the full quote document, parties, and hedge instructions in the application database.
[
{
"id": "880000000000000003",
"debit_account_id": "870000000000000002",
"credit_account_id": "870000000000000003",
"amount": "10000",
"user_data_128": "890000000000000001",
"user_data_64": "915000",
"user_data_32": 1,
"ledger": 840,
"code": 300,
"flags": 1
},
{
"id": "880000000000000004",
"debit_account_id": "870000000000000002",
"credit_account_id": "870000000000000004",
"amount": "50",
"user_data_128": "890000000000000001",
"user_data_64": "915000",
"user_data_32": 1,
"ledger": 840,
"code": 301,
"flags": 1
},
{
"id": "880000000000000005",
"debit_account_id": "870000000000000006",
"credit_account_id": "870000000000000007",
"amount": "9150",
"user_data_128": "890000000000000001",
"user_data_64": "915000",
"user_data_32": 1,
"ledger": 978,
"code": 302,
"flags": 0
}
]The example uses user_data_64 for a fixed-point rate snapshot and user_data_32 for the quote revision. Those conventions are application-defined; do not infer pricing from them without the durable quote record and its declared scale.
Submit and reconcile the accepted quote:
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./accepted-fx-quote.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 880000000000000003,880000000000000004,880000000000000005 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 840 --limit 20 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 978 --limit 20 --jsonA successful create has persisted: true and an empty responsePayload. Item conflicts arrive as HTTP 409 with tbResults. The public response can expose only the first ten conflict entries, so always look up every stable ID rather than assuming an unlisted item succeeded or failed.
The CLI's --json output is terminal-logger decorated, not guaranteed clean stdout for a direct jq pipeline. Use the raw API for machine-to-machine automation.
Node.js implementation
@parix/tigerbeetle-node is the published Node.js client for TigerBeetle-shaped operations on the Parix HTTPS gateway. It does not open a native TigerBeetle connection. Pin a package version approved for your environment, configure { baseUrl, apiKey, databaseId } with a database-scoped API key, keep the client behind an application boundary, and validate behavior before production traffic. The adapter returns create result arrays and does not expose the raw gateway persisted field, so this example requires exact post-write lookup of every stable ID before advancing a durable workflow.
The following example assumes the quote has already passed rate, limit, eligibility, and expiry checks and has been durably accepted with stable IDs. Its lookup-first flow is safe after a process restart or ambiguous HTTP response.
import type {
Account,
CreateAccountResult,
CreateTransferResult,
Transfer,
} from '@parix/tigerbeetle-node';
import { AccountFlags, CreateTransferError, TransferFlags, createClient } from '@parix/tigerbeetle-node';
const client = createClient({
baseUrl: process.env.PARIX_BASE_URL ?? 'https://parix.io',
apiKey: mustGetEnv('PARIX_API_KEY'),
databaseId: mustGetEnv('PARIX_DATABASE_ID'),
});
const ledger = { usd: 840, eur: 978 } as const;
const accountId = {
usdTreasury: 870000000000000001n,
customerUsd: 870000000000000002n,
usdPool: 870000000000000003n,
usdFeeRevenue: 870000000000000004n,
eurTreasury: 870000000000000005n,
eurPool: 870000000000000006n,
customerEur: 870000000000000007n,
} as const;
const acceptedQuote = {
id: 890000000000000001n,
revision: 1,
rateScaled: 915000n,
sourceMinor: 10000n,
feeMinor: 50n,
destinationMinor: 9150n,
transferId: {
source: 880000000000000003n,
fee: 880000000000000004n,
destination: 880000000000000005n,
},
} as const;
function account(id: bigint, ledgerId: number, code: number, flags: number): Account {
return {
id,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
reserved: 0,
ledger: ledgerId,
code,
flags,
timestamp: 0n,
};
}
function transfer(input: {
id: bigint;
debitAccountId: bigint;
creditAccountId: bigint;
amount: bigint;
ledger: number;
code: number;
flags?: number;
quoteId?: bigint;
rateScaled?: bigint;
quoteRevision?: number;
}): Transfer {
return {
id: input.id,
debit_account_id: input.debitAccountId,
credit_account_id: input.creditAccountId,
amount: input.amount,
pending_id: 0n,
user_data_128: input.quoteId ?? 0n,
user_data_64: input.rateScaled ?? 0n,
user_data_32: input.quoteRevision ?? 0,
timeout: 0,
ledger: input.ledger,
code: input.code,
flags: input.flags ?? TransferFlags.none,
timestamp: 0n,
};
}
function getHttpStatus(cause: unknown): number | undefined {
if (!cause || typeof cause !== 'object' || !('status' in cause)) return undefined;
const status = (cause as { status?: unknown }).status;
return typeof status === 'number' ? status : undefined;
}
function wasDefinitelyRejected(cause: unknown): boolean {
const status = getHttpStatus(cause);
return status !== undefined && [400, 401, 402, 403, 404, 409, 429].includes(status);
}
function sameAccount(actual: Account, intended: Account): boolean {
return (
actual.id === intended.id &&
actual.user_data_128 === intended.user_data_128 &&
actual.user_data_64 === intended.user_data_64 &&
actual.user_data_32 === intended.user_data_32 &&
actual.reserved === intended.reserved &&
actual.ledger === intended.ledger &&
actual.code === intended.code &&
actual.flags === intended.flags
);
}
function sameTransfer(actual: Transfer, intended: Transfer): boolean {
return (
actual.id === intended.id &&
actual.debit_account_id === intended.debit_account_id &&
actual.credit_account_id === intended.credit_account_id &&
actual.amount === intended.amount &&
actual.pending_id === intended.pending_id &&
actual.user_data_128 === intended.user_data_128 &&
actual.user_data_64 === intended.user_data_64 &&
actual.user_data_32 === intended.user_data_32 &&
actual.timeout === intended.timeout &&
actual.ledger === intended.ledger &&
actual.code === intended.code &&
actual.flags === intended.flags
);
}
async function accountsExistExactly(batch: Account[]): Promise<boolean> {
const found = await client.lookupAccounts(batch.map(({ id }) => id));
if (found.length === 0) return false;
const intendedById = new Map(batch.map((item) => [item.id, item]));
if (
found.length !== batch.length ||
!found.every((item) => {
const intended = intendedById.get(item.id);
return intended !== undefined && sameAccount(item, intended);
})
) {
throw new Error('FX account IDs are partially present or have different immutable fields');
}
return true;
}
async function transfersExistExactly(label: string, batch: Transfer[]): Promise<boolean> {
const found = await client.lookupTransfers(batch.map(({ id }) => id));
if (found.length === 0) return false;
const intendedById = new Map(batch.map((item) => [item.id, item]));
if (
found.length !== batch.length ||
!found.every((item) => {
const intended = intendedById.get(item.id);
return intended !== undefined && sameTransfer(item, intended);
})
) {
throw new Error(`${label} IDs are partially present or have different immutable fields`);
}
return true;
}
async function createAccountsOrReconcile(batch: Account[]): Promise<void> {
if (await accountsExistExactly(batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createAccounts(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
if (await accountsExistExactly(batch)) return;
throw new Error('Account outcome is ambiguous; retry only with the same IDs and fields', { cause });
}
if (await accountsExistExactly(batch)) return;
if (results.length === 0) {
throw new Error('Account write was not confirmed by lookup; do not advance or change the payload');
}
throw new Error(
`createAccounts rejected and lookup found no exact batch: ${JSON.stringify(results)}`,
);
}
async function createTransfersOrReconcile(label: string, batch: Transfer[]): Promise<void> {
if (await transfersExistExactly(label, batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createTransfers(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
if (await transfersExistExactly(label, batch)) return;
throw new Error(`${label} outcome is ambiguous; retry only with the same IDs and fields`, { cause });
}
if (await transfersExistExactly(label, batch)) return;
if (results.length === 0) {
throw new Error(`${label} was not confirmed by lookup; do not advance or change the payload`);
}
throw new Error(
`${label} rejected${results.some((item) => item.result === CreateTransferError.exceeds_credits) ? ' (capacity)' : ''}` +
` and lookup found no exact batch: ${JSON.stringify(results)}`,
);
}
async function settleAcceptedQuote(): Promise<void> {
await createTransfersOrReconcile('accepted FX quote', [
transfer({
id: acceptedQuote.transferId.source,
debitAccountId: accountId.customerUsd,
creditAccountId: accountId.usdPool,
amount: acceptedQuote.sourceMinor,
ledger: ledger.usd,
code: 300,
flags: TransferFlags.linked,
quoteId: acceptedQuote.id,
rateScaled: acceptedQuote.rateScaled,
quoteRevision: acceptedQuote.revision,
}),
transfer({
id: acceptedQuote.transferId.fee,
debitAccountId: accountId.customerUsd,
creditAccountId: accountId.usdFeeRevenue,
amount: acceptedQuote.feeMinor,
ledger: ledger.usd,
code: 301,
flags: TransferFlags.linked,
quoteId: acceptedQuote.id,
rateScaled: acceptedQuote.rateScaled,
quoteRevision: acceptedQuote.revision,
}),
transfer({
id: acceptedQuote.transferId.destination,
debitAccountId: accountId.eurPool,
creditAccountId: accountId.customerEur,
amount: acceptedQuote.destinationMinor,
ledger: ledger.eur,
code: 302,
flags: TransferFlags.none,
quoteId: acceptedQuote.id,
rateScaled: acceptedQuote.rateScaled,
quoteRevision: acceptedQuote.revision,
}),
]);
}
async function main(): Promise<void> {
const constrainedHistory = AccountFlags.history | AccountFlags.debits_must_not_exceed_credits;
await createAccountsOrReconcile([
account(accountId.usdTreasury, ledger.usd, 100, AccountFlags.history),
account(accountId.customerUsd, ledger.usd, 110, constrainedHistory),
account(accountId.usdPool, ledger.usd, 200, constrainedHistory),
account(accountId.usdFeeRevenue, ledger.usd, 210, AccountFlags.history),
account(accountId.eurTreasury, ledger.eur, 100, AccountFlags.history),
account(accountId.eurPool, ledger.eur, 200, constrainedHistory),
account(accountId.customerEur, ledger.eur, 110, constrainedHistory),
]);
await createTransfersOrReconcile('test USD funding', [
transfer({
id: 880000000000000001n,
debitAccountId: accountId.usdTreasury,
creditAccountId: accountId.customerUsd,
amount: 100050n,
ledger: ledger.usd,
code: 10,
}),
]);
await createTransfersOrReconcile('test EUR liquidity', [
transfer({
id: 880000000000000002n,
debitAccountId: accountId.eurTreasury,
creditAccountId: accountId.eurPool,
amount: 500000n,
ledger: ledger.eur,
code: 10,
}),
]);
await settleAcceptedQuote();
}
function mustGetEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
try {
await main();
} finally {
client.destroy();
}Do the quote-expiry check before writing an accepted-quote record. A restarted worker must first reconcile the stable IDs even if the quote is now past its original expiry; rejecting that replay on the current clock can strand a conversion that already committed.
Failure and retry handling
| Signal or condition | Meaning | Action |
|---|---|---|
Empty create result [] | Adapter reported no item conflicts | Raw HTTPS: also require persisted: true. Node adapter: confirm every stable ID by exact post-write lookup before advancing |
| Nonempty create result | One or more items were rejected | The adapter unwraps HTTP 409 + tbResults into this array. Lookup every stable ID; linked legs must be observed as the complete exact chain or escalated |
| Source balance exceeded | Customer lacks principal plus fee | Return insufficient funds; do not retry unchanged |
| Destination pool balance exceeded | Available liquidity cannot fund the payout | Reject or reprice before acceptance; replenish through a separately reconciled treasury event |
| Quote expired before durable acceptance | The customer no longer has a valid executable quote | Produce a new quote with new business identity and transfer IDs |
| Market rate changes after acceptance | Pricing moved after the business event became immutable | Settle the accepted amounts; do not change the payload under the same transfer IDs |
| Linked leg fails | TigerBeetle rejected the chain | No conversion leg commits; correct the deterministic cause before creating a new accepted quote |
HTTP 400 | Strict schema, ledger, code, flag, or field validation failed | Fix the request; do not retry unchanged |
HTTP 401, 402, 403, or 404 | Credential, billing, scope, environment, or database is wrong | Stop and correct configuration |
HTTP 429 | Transient admission pressure or a durable quota was reached | Back off with jitter only for transient rate limits; wait for quota reset or change workload/plan for durable exhaustion |
Timeout, disconnect, HTTP 500, or 503 | The write outcome may be ambiguous | Lookup all three IDs and exact immutable fields, then retry only the same absent payload |
| External hedge fails after customer settlement | Ledger conversion committed but treasury execution failed | Keep the customer conversion immutable; manage and escalate pool exposure instead of silently rewriting or auto-reversing it |
| Approved customer reversal | Policy requires a compensating conversion | Price and submit new stable reversal legs; ensure returned value is still available and retain both histories |
The Node adapter returns item conflicts as a non-empty result array rather than throwing them as transport errors. Keep result-array reconciliation outside the transport catch.
Test scenarios
Run tests in an isolated non-production database with dedicated credentials and ID namespaces.
| Scenario | Setup/action | Expected result |
|---|---|---|
| Account bootstrap | Create all seven accounts | Empty result; repeat resolves to the exact existing accounts |
| Happy-path conversion | Fund customer and EUR pool, then submit the three linked legs | All three commit; USD principal and fee debit, EUR payout credits |
| Duplicate accepted quote | Deliver the same three IDs and payload twice | One conversion exists; the second delivery reconciles without another movement |
| Transfer-ID collision | Reuse a leg ID with a different amount or account | Exact-field comparison rejects the collision |
| Insufficient customer USD | Submit principal plus fee above available USD | Entire chain fails; no fee and no EUR payout |
| Insufficient EUR liquidity | Submit a destination payout above the pool balance | Entire chain fails; customer USD remains unchanged |
| Invalid final account | Use a missing customer EUR account on the final leg | Source principal and fee also roll back |
| Open linked chain | Set linked on the final leg | Chain is rejected with no movement |
| Unlinked fee leg | Omit linked from the principal or fee before the final leg | Test must fail policy validation before submission; never send a partial chain |
| Quote rounding boundary | Price a source amount that leaves a fixed-point remainder | Destination follows the documented rounding rule exactly |
| Quote expires before acceptance | Advance the application clock beyond expiry before durable acceptance | No ledger request is sent |
| Rate moves after acceptance | Change market data before retrying an accepted quote | Retry uses original amounts and IDs |
| Ambiguous response | Drop the HTTP response after submission | Lookup confirms the full exact chain or retry uses the same payload |
| Shared query without ledger | Query a Developer database without ledger | Request is rejected; queries with 840 and 978 succeed |
| Ledger quota | Allocate more external currency ledgers than the Developer plan permits | Durable quota error; no blind retry |
| Hedge failure | Commit the customer conversion and fail the external hedge definitively | Customer trade remains posted; exposure incident and treasury workflow open |
| Environment isolation | Run sample IDs against test credentials | Only the test database changes |
Production operations
- Keep quote calculation, market-data source, spread, fee, rounding, expiry, customer consent, and stable-ID mapping in a durable application record.
- Reconcile each accepted quote to its exact Parix transfer IDs. Reconcile pool balances separately to bank settlement and hedge fills.
- Monitor spendable liquidity by currency, pool concentration, stale quotes, rate-source health, rejected chains, ambiguous outcomes, ledger mapping quota, and exposure beyond treasury limits.
- Stop quoting a pair before the destination pool reaches its hard balance constraint. The ledger constraint is the final guardrail, not the primary liquidity-control loop.
- Use separate API keys, databases, ID namespaces, pool accounts, alerting, and reconciliation jobs for test and production.
- Restrict treasury funding, pool adjustments, fee adjustments, and reversals to reviewed least-privilege services.
- Scale hot pools deliberately. Sharding a pool changes routing and reconciliation requirements; never create untracked liquidity fragments merely to avoid a hot account.
- Batch below both the 8,190 public-schema maximum and the active plan limit. Never split one linked conversion chain across requests.
- Use a durable outbox/inbox for accepted quotes and settlement work. Preserve the same payload across process restarts and retries.
- Practice market-data outages, depleted-pool controls, key rotation, ambiguous-write reconciliation, and hedge-provider failure before production launch.