providers-specs
TRES Fee Recording
TRES Fee Recording
TRES models a transfer as one parent transaction with multiple child subtransactions. Fees should be separate child rows, not netted into the principal transfer amount.
Internal Source Data
Our internal transactions row keeps:
amountMinor: principal amount.feeMinor: total fee amount for display/reconciliation.metadata.networkFeeMinorormetadata.networkFee: network/provider fee.metadata.sendFeeMinor,metadata.platformFeeMinor, ormetadata.platformFee: Daxchain/platform fee.
The wallet ledger can still debit the total spend (amount + fees) in one
ledger_entries row. The TRES export expands that accounting view into
separate subtransactions.
TRES Payload Shape
Use buildTresManualTransferPayload() from lib/providers/tres.ts.
For a send or payout:
- Create the parent manual transaction with the provider tx hash/id as
identifier. - Create one
OUTFLOWsubtransaction for the principal amount. - Create one
OUTFLOWsubtransaction for the network fee when non-zero. - Create one
OUTFLOWsubtransaction for the platform fee when non-zero.
For ERC-20/TRC-20 gas, only set networkFeeAssetId to the native gas asset
when the source fee amount is actually denominated in that gas asset. If the
source amount is estimated or charged in the token itself, keep the fee asset
the same as the transfer asset.
Implementation Approach: Option A (Fire-and-forget hook)
The current implementation calls tres.exportManualTransfer(...) inline from
worker/jobs/send.ts (after transactions.status='completed') and
worker/jobs/payouts.ts (after payout_rows.status='confirmed'). The call is
wrapped in a try/catch and failures are recorded as tres.export_failed
audit-log rows. The provider tx hash is the TRES identifier, so retries
are idempotent on the TRES side.
Drawbacks
This approach is intentionally minimal. Known trade-offs:
- No retry / no durable queue. A failed export is logged once and abandoned. Transient TRES outages, auth-token expiry, or 429s cause permanent gaps in the financial export until the row is re-exported manually.
- No backfill path. There is no
tres_export_statuscolumn ontransactions/payout_rows, so re-exporting after a TRES outage requires an ad-hoc script that filters byaudit_logand reconstructs the source rows. - No deduplication beyond the TRES identifier. Two successful
exports with different identifiers (e.g., chain tx hash arrives late
and the fallback
send:<id>was used first) would create two TRES parent transactions. Today's jobs run after the tx hash is set, but any future code path that triggers earlier risks a duplicate. - Coupling to the user-facing job's lifetime. The export runs in the same async function as the send/payout completion handler. A long TRES request blocks the job from returning, which can starve the in-process job runner. The catch suppresses errors, not latency.
- No observability surface. Failures land in
audit_logonly — no admin page showing pending vs. exported state, no metric counter, no alert when the failure rate spikes. - Mapping config is environment-only. Per-asset TRES wallet ids,
type ids, and platform names are read from
TRES_*env vars at call time. Multi-tenant / per-merchant TRES routing is not supported without a schema change. - Live mode is intentionally unimplemented. Following the existing
provider pattern,
LiveTresthrowsunsupported('TRES'). Switching to a real GraphQL client requires writing the login/refresh flow plus thecreateManualTransaction+createSubTransactionmutations from the Postman collection.
If any of these become real problems (especially the first two), migrate to
Option B: a tres_exports queue table plus a dedicated worker job.
Implementation Approach: Option B (Decoupled queue + worker)
Option B replaces the inline call in worker/jobs/send.ts and
worker/jobs/payouts.ts with a write-then-drain pattern: the user-facing job
records that an export is needed and a separate worker drains the queue,
calling tres.exportManualTransfer(...) with retry and backoff.
Schema
Two viable shapes — pick one, do not maintain both:
B.1 — Per-source export columns (lighter, recommended for a single tenant).
Add migration lib/db/migrations/NNNN_tres_export_status.sql:
ALTER TYPE tres_export_status_enum AS ENUM (
'pending', 'exported', 'failed'
);
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS tres_export_status tres_export_status_enum,
ADD COLUMN IF NOT EXISTS tres_tx_id text,
ADD COLUMN IF NOT EXISTS tres_attempts integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tres_last_error text,
ADD COLUMN IF NOT EXISTS tres_exported_at timestamptz;
ALTER TABLE payout_rows
ADD COLUMN IF NOT EXISTS tres_export_status tres_export_status_enum,
ADD COLUMN IF NOT EXISTS tres_tx_id text,
ADD COLUMN IF NOT EXISTS tres_attempts integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tres_last_error text,
ADD COLUMN IF NOT EXISTS tres_exported_at timestamptz;
CREATE INDEX IF NOT EXISTS transactions_tres_pending_idx
ON transactions (tres_export_status)
WHERE tres_export_status = 'pending';
CREATE INDEX IF NOT EXISTS payout_rows_tres_pending_idx
ON payout_rows (tres_export_status)
WHERE tres_export_status = 'pending';
Append the file to lib/db/migrations/meta/_journal.json and mirror the
new columns in lib/db/schema/ (per-domain files, not a single schema.ts).
B.2 — Standalone tres_exports queue (cleaner for multi-tenant or audit).
CREATE TABLE IF NOT EXISTS tres_exports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source_type text NOT NULL CHECK (source_type IN ('send', 'payout_row')),
source_id uuid NOT NULL,
status tres_export_status_enum NOT NULL DEFAULT 'pending',
tres_tx_id text,
attempts integer NOT NULL DEFAULT 0,
last_error text,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
exported_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source_type, source_id)
);
CREATE INDEX IF NOT EXISTS tres_exports_pending_idx
ON tres_exports (status, next_attempt_at)
WHERE status = 'pending';
The unique (source_type, source_id) constraint plus the TRES identifier
(provider tx hash) guarantee at-most-once delivery even if the worker is
restarted mid-flight.
Trigger sites (replacing today's inline call)
In worker/jobs/send.ts after transactions.status='completed' and in
worker/jobs/payouts.ts after payout_rows.status='confirmed':
// B.1 form
await db.update(transactions)
.set({ tresExportStatus: 'pending' })
.where(eq(transactions.id, sendId));
// B.2 form
await db.insert(tresExports).values({
sourceType: 'send',
sourceId: sendId,
}).onConflictDoNothing();
No payload is built here — the worker does that with fresh row data, so late-arriving fields (e.g., a tx hash that lands after the audit row) are captured correctly.
Worker
New module worker/jobs/tresExport.ts:
- Select up to N pending rows with
next_attempt_at <= now()ordered bynext_attempt_at. UseFOR UPDATE SKIP LOCKEDso multiple workers can run safely. - For each row, hydrate the source from
transactions/payout_rows, build the payload viabuildTresManualTransferPayload(...)+tresMappingForAsset(...), calltres.exportManualTransfer(...). - On success: mark
exported, storetres_tx_id, setexported_at = now(). - On failure: increment
attempts, storelast_error, setnext_attempt_at = now() + backoff(attempts)(exponential with jitter). After N attempts (default 10), markfailedand stop retrying. - Drain in a loop until the queue is empty or a per-tick budget is hit.
Schedule via the existing in-process job runner (worker/jobs/index.ts) with
a fixed cadence (e.g., every 30s) plus an opportunistic enqueue from the
send/payout jobs to drain immediately when the system is idle.
Backfill
Re-export after a TRES outage:
-- B.1
UPDATE transactions
SET tres_export_status = 'pending', tres_attempts = 0,
tres_last_error = NULL
WHERE id = ANY($1::uuid[]);
-- B.2
UPDATE tres_exports
SET status = 'pending', attempts = 0, last_error = NULL,
next_attempt_at = now()
WHERE source_type = 'payout_row' AND source_id = ANY($1::uuid[]);
The TRES identifier (provider tx hash) is unchanged across attempts, so
TRES treats the second call as an upsert and no duplicate parent
transaction is created.
Observability
Once the queue exists, expose two thin admin views:
app/admin/tres/page.tsx— counts by status, last error per row, "retry now" action forfailedrows.- A
/api/admin/tres/rundebug route that triggers the worker once (gated to admin role) for use in Bruno scenarios.
Trade-offs vs. Option A
| Concern | Option A | Option B |
|---|---|---|
| Lines of code | ~80 | ~300+ |
| Schema migration | none | one |
| Survives TRES outage | no | yes |
| Backfill is a one-liner SQL | no | yes |
| Failure is observable in UI | no | yes |
| Decouples job latency from TRES | no | yes |
| Multi-tenant routing | no | yes (B.2) |
| Admin "retry now" action | no | yes |
Pick Option B when the financial-export integrity bar is high enough that silently dropping a row on a transient TRES failure is unacceptable, or when Finance needs visibility into the export pipeline. Otherwise Option A is enough.