Skip to content

Consolidate reports-server backlog: partner fixes, test suite + CI, Swapter/NYM/Revolut plugins, v2 dashboard - #232

Open
j0ntz wants to merge 27 commits into
masterfrom
jon/reports-backlog
Open

Consolidate reports-server backlog: partner fixes, test suite + CI, Swapter/NYM/Revolut plugins, v2 dashboard#232
j0ntz wants to merge 27 commits into
masterfrom
jon/reports-backlog

Conversation

@j0ntz

@j0ntz j0ntz commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Consolidates every open strand of edge-reports-server work into one branch, per
the umbrella task
reports-server: Umbrella.

jon/reports-backlog is what has been running on deploy, so this is largely a
request to make master match what production already serves, plus two fixes
found while assembling it.

Supersedes #228,
#230 and
#231, all closed in
favour of this one. Their content is carried here in full: the branch was
verified file-by-file against each of them before they were closed.

What is in it

Partner fixes (3dce03f..1b95c0b) — nexchange, Xgram, ChangeNow delisted
assets, LetsExchange native-network inference, partner asset/payment mappings,
Banxa Klarna and the ZEC fallback, the getTxInfo endpoint disable, and throwing
rather than pricing tokens as native gas tokens.

Test suite and CI (397ad8a, e2a6e17) — test/util.test.ts imported from
../lib/util, a gitignored build artifact, so a clean checkout could not run a
single test. Repointed at the source module, stale fixture keys renamed, and a CI
job added so the suite cannot silently break again.

Three reporting plugins (0748c28, 8955457, 1df0e59) — Swapter, NYM and
Revolut, each driven against the live partner API before shipping. Revolut had
been recorded as credential-blocked; it was pointed at a host and path that do
not exist, and the real Ramp API answers with the key already in env.json.
Its orders endpoint returns one row per payment attempt, so attempts are
collapsed per orderId before anything is emitted.

v2 dashboard (e429c6e, 42dd971, ee96687, ede909a) — an isolated
dashboard at /v2/, with v1 untouched. Revenue is a first-class metric: where a
partner reports Edge's actual fee it is stored on the transaction as
revenueUsd/revenueSource and summed through the analytics cache, and
elsewhere it is estimated at read time from a per-partner revShareRate on the
app doc. Rates are never committed, since they are commercial terms and this repo
is public.

Blocked-provider record (b921135) — docs/blocked-partner-reporting.md,
recording what nexchange, Simplex and Bridgeless each need before they can work,
with the live request and exact response behind every claim. Tracked separately
at
its own task.

Review fixes (new here, from the reviewer bots and an independent multi-agent
review) — partner-reported revenue never reached CouchDB, because
checkUpdateTx compares an explicit field list that revenueUsd/revenueSource
were missing from, and revenue arrives late by nature. The v2 dashboard built
TODAY from the local calendar day while every bucket index is UTC, so away from
UTC a day's figures landed in the wrong slot; finishing that migration also took
fmtDate, both month-rebucketing sites and the tooltip year. A ?apiKey= seed
is now stripped from the address bar rather than left in history and copied
links. Cleaner-failure logs no longer serialize the whole rawTx, which carried
counterparty addresses and txids into logs. Every partner pagination loop now
runs under a hard page cap, so a stuck cursor or a page that never shortens can
no longer let a partner endpoint decide how long the worker runs. And
revenueSource keeps its literal union: asValue's type parameter was inferred
as string[], quietly costing every consumer the compile-time guarantee.

nexchange hardening (87c7d73, new here) — two defects that only surface
against live data. The nullable response fields used asOptional, which supplies
its fallback for a missing key but still throws when the key is present with an
unexpected type, so one odd row aborts the page, exhausts the retries and stalls
the window permanently. And a zero contract address (0x000…0) denotes the
chain's native gas asset, but only a missing or empty address was treated as
native, so createTokenId would mint a tokenId for the gas asset and mis-route
its rates and volume.

Testing

verify-repo.sh --base origin/master passes end to end: CHANGELOG, install,
prepare, eslint over all 25 changed files, and npm test at 106 passing.

The three reporting plugins were each driven against the live partner API when
they were written, read-only with the GUI's existing credentials and never
against production CouchDB: Revolut 1,162 real orders (1,359 raw rows collapsed,
0 duplicate orderIds), NYM 47, Swapter 14, every emitted row validated against
the repo's own asStandardTx with zero failures.

Not verified here: nexchange cannot be exercised at all until a reporting-scoped
key exists, so its fixes are covered by the type checker, eslint and the suite
rather than by live data. The reasoning behind each is in the commit and in
docs/blocked-partner-reporting.md.

Partner ingestion robustness (also new here) — the independent review turned
up four correctness defects in partner code that predates this branch, and all
four are fixed.

ChangeNow, Rango and Xgram each let a per-transaction failure escape the
processing loop. That reads as the safe choice, but it stalls the partner: the
run halts, progress saves just short of the bad row, and every later poll
re-fetches the same range and dies on the same row, so nothing newer is ever
recorded. Xgram was worse again, because its loop sits outside the try/catch
guarding the fetch, so the throw rejected queryXgram entirely and discarded
every order already processed in that run. Emitting the row anyway is not the
alternative either, since that prices a token with the chain's gas-token rate.
So the row is quarantined: dropped, never emitted with wrong data, and reported
at error level with its id and a per-run count. Ingestion continues past it.

LetsExchange treated two different situations identically, so a recent order
whose network could not be resolved was saved with chainPluginId, evmChainId
and tokenId permanently undefined and no trace in the logs. The two cases are
now distinguished and the unresolvable one is reported, with guidance that
splits single-chain tickers (add to the fallback map) from multi-chain ones
(missing API data, raise with the partner).

Xgram and ChangeNow also gain the page cap the other plugins have, and
describeRawTx now lives in src/util.ts shared by all five plugins rather
than duplicated in two.

MMrj9 and others added 20 commits July 31, 2026 15:05
Co-authored-by: Cursor <cursoragent@cursor.com>
When an asset has a contract address it is a token, but several plugins
silently fell back to a native (tokenId: null) mapping when the token
could not be resolved. That prices the token with the chain's gas-token
rate and overcounts volume whenever the token is worth less than the gas
token.

- nexchange: throw when a contract-bearing asset is on a chain whose
  tokenType is missing, instead of returning tokenId: null.
- changenow: drop the try/catch around createTokenId that swallowed
  failures and returned tokenId: null.
- rango: drop the per-tx try/catch that logged and continued, silently
  dropping any transaction whose asset could not be resolved.

All three plugins paginate oldest-to-newest and persist progress on
throw, so a failing order halts and is retried next run rather than being
mispriced or silently dropped.

Also add the SUI and MONAD chain mappings to rango: these were
previously dropped silently and would now halt the plugin. Verified by
reprocessing the last three months of orders (nexchange 22.7k, changenow
61.9k, rango 2.9k) with zero processing failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Some older LetsExchange transactions return null network fields for
unambiguous native assets (e.g. ETH, BTC, XRP), causing processing to
throw "Missing network" and stalling the query. Add a currency-to-network
fallback for 1:1 native tickers so these transactions process correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>
These mappings were causing the query engine to halt and stop scanning
forward for the affected partners (fail-closed design), blocking
collection of newer transactions until the mapping was added.

- SideShift: map SWARMS on Solana to its mint address (delisted from the
  SideShift coins API, so added to DELISTED_COINS).
- Rango: map the SONIC blockchain to the `sonic` Edge pluginId.
- Banxa: map the "Primer Paypal Pay" and "Primer Google Pay" payment
  types to paypal and googlepay respectively.

Co-authored-by: Cursor <cursoragent@cursor.com>
The currency cache was built from the `currencies?active=true` endpoint.
When ChangeNow deactivates an asset (e.g. DASH), it disappears from the
active list while historical transactions still reference it. The lookup
then misses and the plugin halts fail-closed, stalling all ChangeNow
transaction collection.

Fetch the full currency list (omit `active=true`) so previously-listed
assets continue to resolve for historical transactions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Banxa removed ZEC from the v2 crypto catalog, so historical ZEC
orders abort the partner query and stall ingestion past July 8.

Co-authored-by: Cursor <cursoragent@cursor.com>
Not private enough and is scrapable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Unblocks Banxa query progress stuck since Jul 24 on unrecognized
KLARNA Checkout payment methods.

Co-authored-by: Cursor <cursoragent@cursor.com>
The suite imported from ../lib/util, a gitignored build artifact that only
exists after a build, so a clean checkout could not run any test. Point it at
the source module instead. The analytics fixtures also still used the old
pluginId key, which the current cleaner rejects.
The suite was not run by CI, which is how it stayed broken. The job installs
with --ignore-scripts and generates the gitignored clientConfig.json before
running mocha.
Queries Swapter's tool-history reporting endpoint and maps each order to a
StandardTx. Pages are buffered so a mid-page failure retries idempotently, and
progress only advances once the full walk completes.
Queries NYM's partner reporting endpoint and converts its native-unit amounts
to major units using the live currencies list. The report timestamp keys off
createdDate because completedDate is null even on settled orders.

processNymTx follows the uniform (rawTx, pluginParams) processor contract, and
an unparseable order fails its page rather than being skipped: skipping would
let the walk complete and advance progress past an order the next run's
lookback can no longer reach.
Queries Revolut Ramp's orders endpoint, authenticated with the X-API-KEY header
that the GUI's existing ramp key uses. Two shapes of that API are easy to get
wrong and are handled explicitly: the start/end bounds are date-only, and the
response is a bare array paged by skip/limit rather than a cursor envelope.

An order id is not unique in the response. Revolut returns one row per payment
attempt, so the same id arrives both COMPLETED and FAILED. Since orderId keys
the StandardTx document, attempts are collapsed to one winner per id, settled
beating unsettled, before anything is emitted.

Native assets resolve to their Edge chain with a null tokenId. Tokens resolve
their chain but leave tokenId undefined: Revolut reports no contract address,
and a guessed one would mis-price the asset.
Adds an isolated /v2/config route returning per-provider rev-share rates and
fiat/swap classification. No v1 route is modified.

The rates live on the app doc in reports_apps, as an optional revShareRate
beside each partner's apiKeys. The rate is a property of the app-partner deal,
so it is per app AND per partner, and it sits with the credentials that define
the relationship: onboarding a partner is one doc edit, with no separate rates
map to forget. The rates are commercial terms and this repo is public, so they
are never committed or placed in config.json. A partner without one contributes
0 estimated revenue.
Ports the redesign prototype into src/demoV2 as a standalone dashboard
served at /v2/, built into dist/v2 by a separate parcel invocation so v1's
entry, bundle, and output are untouched. The prototype's sample-data layer
is replaced by the real /v1 API (getAppId auth, getPluginIds, one analytics
POST for all providers) plus /v2/config for rev-share and provider types.

A bad or missing apiKey now redirects to a key-entry screen instead of
hanging: getAppId returns plain text on 400, so the response is checked
before parsing and fetch errors are surfaced.
Some partner APIs report Edge's actual fee per order; estimating that same
number as volume times a configured rate discards a fact we already hold. This
adds the pair StandardTx.revenueUsd / revenueSource: the actual USD figure,
stored at ingest as a fact about the order, and the vocabulary that lets
downstream consumers tell a reported figure from a derived one.

Revolut is the first reporter: fees_partner_currency.partner_fee, pre-converted
to USD by Revolut, taken only on settled orders (an unsettled attempt's fee is
not revenue) and only when the settlement currency is USD (a conversion this
plugin cannot do honestly).

The cache engine sums reported revenue into the analytics buckets, and the v2
dashboard uses it directly wherever a provider reports, marked with a check in
the provider table. Providers that report nothing keep the estimate, computed
at read time from the app doc's per-partner revShareRate, so correcting a rate
fixes history immediately while reported figures stay immutable. Pair rows
attribute bucket revenue proportionally to volume share, the same rule the
existing tx attribution uses.

The revenue keys are optional at the type level rather than spelled out as
explicit undefineds across every partner plugin; the cleaner still validates
them when present. Cache docs written before this field lack it until the next
cache rebuild, which fills history from stored txs with no partner re-query.
The v2 config route carried its own hand-typed fiat/swap table, and it had
drifted from the v1 registry it was copied from. It keyed Ionia gift cards,
Fox Exchange and NYM by filename or lowercase rather than by their real
pluginIds, and omitted banxa2, banxa3 and gebo entirely. Unknown pluginIds
default to 'swap' on the client, so all four fiat providers among those
rendered under the swap filter with a swap badge.

Projecting the table from src/demo/partners.ts removes the duplicate rather
than correcting it in place, so the two cannot drift again. bridgeless is kept
as an explicit extra: it is registered in edge-exchange-plugins but has never
reported a transaction here, so it has no v1 registry entry.

This cannot be derived from the data instead. StandardTx.exchangeType exists
but is optional and postdates most ingested history, so it is absent from
nearly every stored transaction, and PartnerPlugin carries no type at all.
nexchange, Simplex and Bridgeless all lack a working reporting plugin, and none
of them is blocked on work this repo can do alone. Record what each one needs,
with the live evidence behind it, so the investigation is not repeated.

nexchange is credential-scoped: its key authenticates but returns 403 on the
reporting resource. Simplex needs a server-side key that only their support
issues, and the shipped plugin also targets a retired host and the wrong auth
header. Bridgeless has no key at all, but its public API carries no timestamp,
no referral filter and no resumable cursor, which is what makes a plugin
infeasible rather than merely expensive.
Two defects in the ported plugin, both of which only surface against live data.

The nullable response fields used asOptional, which supplies its fallback when a
key is missing but still throws when the key is present with an unexpected type.
One odd row therefore aborts the whole page, exhausts the retries, and stalls the
window, so the same bad row is re-hit every cycle and the plugin never makes
progress. asMaybe degrades that row instead.

A zero contract address (0x000...0) denotes the chain's native gas asset, not a
token, but only a missing or empty address was treated as native. createTokenId
would otherwise mint a non-null tokenId for the gas asset and mis-route its rates
and volume. Banxa and Moonpay special-case the zero address the same way.

Also corrects the blocked-provider doc, which described nexchange as deliberately
absent from this repo. That was true of the split branches; this branch carries
one copy, so there is nothing left to deduplicate. The credential blocker is
unchanged.
Comment thread src/demoV2/index.html
Comment thread src/queryEngine.ts
Comment thread src/demoV2/index.html
Three review findings, each a real defect.

Partner-reported revenue never reached CouchDB. checkUpdateTx compares an
explicit field list, and revenueUsd/revenueSource were not on it, so a re-poll
that fills in a fee without touching an already-tracked field looked unchanged
and the write was skipped. Revenue arrives late by nature: a partner can settle
the fee after the order row already exists.

The v2 dashboard built TODAY from the browser's local calendar day while
TODAY_DAY_INDEX and every bucket index are computed in UTC, so anywhere but UTC
a day's volume and revenue landed in the wrong slot and the analytics fetch
window was misaligned by up to a day. The comment claimed UTC; now the code
matches it.

The dashboard also left a ?apiKey= seed sitting in the address bar, where it
persists in history, copied links and referrers. It is now stripped via
replaceState once read. The key still travels as a query param on the API calls
themselves, because that is the /v1 auth contract and not something this branch
can change unilaterally.
Comment thread src/demoV2/index.html
Comment thread src/partners/nym.ts Outdated
Moving TODAY to UTC midnight fixed the bucket indexes but left the presentation
layer reading those UTC timestamps with local getters, which is worse than the
original inconsistency because the two halves now disagree. West of UTC a
UTC-midnight date renders as the previous local day, so day labels and tooltips
shifted by one, and month rebucketing in provBuckets and pairBuckets folded the
first UTC day of each month into the previous month on the 12m and 24m charts.
fmtDate, both rebucketing sites and the tooltip year now read UTC.

The cleaner-failure logs in nym and revolut serialized the entire rawTx, which
carries counterparty addresses and transaction ids, into centralized logs where
anyone with log access could recover them. Triage needs the record's id and the
field names it actually arrived with, which is what identifies shape drift; the
full record stays retrievable from Couch by that id.
Comment thread src/partners/nexchange.ts Outdated
j0ntz added 4 commits August 10, 2026 15:40
Every ingestion loop terminated only on a signal from the partner: a null
cursor, a short page, or a reported total. That makes a partner endpoint able to
decide how long our worker runs, so a stuck cursor or a page that never shortens
would spin indefinitely while the in-memory batch kept growing.

Each loop now runs under a hard page cap, and hitting it is logged rather than
silent. The cap changes nothing about correctness: NYM, Revolut and Swapter only
advance their saved progress once the walk completes, so a capped run re-queries
the remainder next cycle exactly like the retry-exhaustion path. nexchange
paginates oldest to newest and advances progress per processed order, so a
capped run keeps the work it did and resumes from there.

xgram shares the pattern and is left alone here; it predates this branch.
asValue's type parameter was left to inference, which widened it to string, so
StandardTx and DbTx had to be hand-relaxed the same way and every consumer lost
the compile-time guarantee: only the runtime cleaner would reject a bad value,
after it may already have been persisted. Pinning the literal tuple restores the
union everywhere, and the optional-key relaxation stays as it was.

Also removes em-dashes from the new comments and docs, per the repo convention
that committed code and documentation use a comma, colon or parentheses instead.
ChangeNow, Rango and Xgram all let a per-transaction failure escape the
processing loop. That looks like the safe choice, and the comments said so, but
it stalls the partner outright: the run halts, progress saves just short of the
bad row, and every later poll re-fetches the same range, hits the same row, and
dies there. Nothing newer is ever recorded, silently, until someone reads the
logs. Xgram was worse again, because its loop sits outside the try/catch that
guards the fetch, so the throw rejected queryXgram entirely and discarded every
order already processed earlier in the same run.

The opposite choice is not available either: emitting the row anyway prices a
token with the chain's gas-token rate, which is the failure changenow.ts and
nexchange.ts both carry comments forbidding.

So the row is quarantined. It is dropped, never emitted with wrong data, and
reported at error level with a per-run count so a recurring mapping gap shows up
as a number rather than as scattered lines. Ingestion continues past it.

Xgram also gains the page cap its four sibling plugins already have.
resolveNetworkCode returned null for two situations that mean different things:
an order predating the API's network fields, which is an expected gap, and a
recent order whose currency is not in the ~45-entry fallback map, which is a
mapping hole. Both were absorbed identically, so the second saved a StandardTx
with chainPluginId, evmChainId and tokenId permanently undefined and the asset
priced by currency code alone. No error, no retry, nothing in the logs.

That is the exact failure changenow.ts and nexchange.ts carry explicit comments
forbidding. The two cases are now distinguished, and the unresolvable one is
logged at error level naming the currency and the map it needs adding to. The
order is still recorded, since dropping it would lose the volume outright; the
gap is simply no longer invisible.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit df0f788. Configure here.

Comment thread src/partners/changenow.ts
Comment thread src/partners/changenow.ts
Comment thread src/partners/letsexchange.ts
Three problems with the quarantine as first written.

ChangeNow's guard wrapped processChangeNowTx, which loads the shared currency
cache internally, so a currencies-endpoint outage stopped looking like an
infrastructure failure and started looking like an unmappable order. Every row
would be skipped while the offset advanced, and with no page cap a transient
outage could walk the entire window recording nothing. The cache is now loaded
once per run outside the guard, where a failure aborts as it did before, and
ChangeNow gains the page cap its siblings already have.

The skip logs identified nothing, so an operator reading them could not tell
which rows were dropped once the watermark moved past them. They now carry the
record id and its field names, via the describeRawTx helper that nym and revolut
already used, lifted into src/util.ts and shared by all five plugins.

The LetsExchange message told operators to add any unresolved currency to
LETSEXCHANGE_CURRENCY_TO_DEFAULT_NETWORK. That map is native-ticker only by
design, so following it for USDT or USDC would invent a default network and
mis-attribute the asset. The message now distinguishes the single-chain case,
which the map fixes, from the multi-chain case, which is missing API data.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants