Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3dce03f
Feat: nexchange plugin
MMrj9 Feb 26, 2026
98dcb27
Throw instead of pricing tokens as native gas tokens
paullinator May 28, 2026
cb7944e
Add Xgram
paullinator May 29, 2026
0ca9ce9
Infer LetsExchange native network when API omits network fields
paullinator Jun 9, 2026
41287a3
Add missing partner asset and payment mappings
paullinator Jun 23, 2026
1ff3e86
Load full ChangeNow currency list to resolve delisted assets
paullinator Jun 29, 2026
3f9845d
Add Banxa historical fallback for delisted ZEC
paullinator Jul 13, 2026
9d7d8c3
Disable getTxInfo endpoint
paullinator Jul 29, 2026
1b95c0b
Add Banxa Klarna Checkout payment type mapping
paullinator Jul 29, 2026
7db6286
Fix broken mocha test suite
j0ntz Aug 19, 2026
9bb9e40
Add CI job to run the test suite
j0ntz Aug 19, 2026
6197e58
Add a log-safe description of a partner payload
j0ntz Aug 19, 2026
20287da
Carry reported revenue and chained pair keys through the analytics cache
j0ntz Aug 19, 2026
209a56a
Add Swapter partner plugin
j0ntz Aug 19, 2026
c6f5abc
Add NYM Swap (nymswap) reporting plugin
j0ntz Aug 19, 2026
b7ceb16
Add Revolut fiat payment provider
j0ntz Aug 19, 2026
74eb8a0
Harden nexchange cleaners and native-asset detection
j0ntz Aug 19, 2026
e9d8dc9
Register the new reporting plugins
j0ntz Aug 19, 2026
e6996c2
Quarantine unprocessable partner transactions instead of stalling
j0ntz Aug 19, 2026
420b0d7
Store chain pluginIds on Paybis and Kado orders
j0ntz Aug 19, 2026
801acab
Add isolated v2 reports dashboard
j0ntz Aug 19, 2026
8fba1f1
Document the blocked partner reporting APIs
j0ntz Aug 19, 2026
fb8780e
WIP Map MoonPay Cash App and Banxa card payouts
paullinator Aug 18, 2026
227ff5a
WIP Map Banxa PIX payouts
paullinator Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Test
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 18
- name: Install dependencies
run: npm install --ignore-scripts
- name: Generate client config
run: node -r sucrase/register src/bin/configure.ts
- name: Run tests
run: npm test
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,31 @@

## Unreleased

- added: CI job that runs the mocha test suite on every pull request
- added: Add Revolut fiat payment provider
- added: Add Swapter reporting
- added: Add NYM Swap (nymswap) reporting
- added: Isolated v2 reports dashboard at /v2/ (real /v1 API data, apiKey auth with redirect on a bad key)
- added: Partner-reported revenue (revenueUsd/revenueSource) on StandardTx, summed through the analytics cache; Revolut reports it, and the v2 dashboard uses reported figures where present with volume x revShareRate as the estimate elsewhere
- added: Document the verified reporting-API status and unblock path for nexchange, Simplex and Bridgeless
- added: v2 dashboard chain filter, using chained pair keys (CODE@pluginId) in the analytics cache
- changed: Update sideshift plugin with new optional API fields
- changed: Query both old and new Sideshift affiliate accounts and merge completed orders to preserve full shift history across an affiliate-account rotation
- changed: Add signature header support to Exolix
- changed: Add index for orderId
- changed: Add EVM chainId, pluginId, and tokenId fields to StandardTx
- changed: Update Lifi to provide chainId, pluginId, and tokenId
- changed: Use rates V3 for transactions with pluginId/tokenId
- changed: Store chain pluginIds on new Paybis, Nym, and Kado orders
- changed: v2 Select all applies only to the currently filtered providers and pairs
- changed: Show ChangeNOW with that spelling in the v2 provider list
- fixed: Classify banxa2, banxa3, gebo and Ionia gift cards as fiat in the v2 dashboard filter, by projecting the provider types from the v1 partner registry instead of a second hand-written copy
- fixed: Quarantine an unprocessable partner transaction instead of halting ingestion behind it in ChangeNow, Rango and Xgram, so one bad row no longer stops every newer transaction from being recorded
- fixed: Report LetsExchange orders whose network cannot be resolved instead of silently dropping their chain and token data
- fixed: Tolerate unexpected types on nexchange's nullable response fields, and treat a zero contract address as the chain's native asset instead of minting a tokenId for the gas asset
- fixed: Moonpay by adding Revolut payment type
- fixed: Use v2 rates API
- fixed: Repair the broken mocha test suite (correct util.test.ts import and stale analytics fixtures) so npm test passes

## 0.2.0

Expand Down
144 changes: 144 additions & 0 deletions docs/blocked-partner-reporting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Blocked partner reporting APIs

Three partners have no working reporting plugin, and none of them is blocked on
work this repo can do alone. This records what each one actually needs, with the
evidence, so the next person does not repeat the investigation.

Everything below was established by a live request against the partner's own
API, not from documentation. Requests were read-only and used the credentials
already in `edge-react-gui/env.json`. Every claim here is reproducible with the
commands shown.

Last verified 2026-07-31.

## Summary

| Partner | Plugin state | Blocked on | Who unblocks it |
| --- | --- | --- | --- |
| nexchange | Ships here, but unverifiable | API key lacks the reporting scope | nexchange support |
| Simplex | `src/partners/simplex.ts`, wrong host and auth | A server-side reporting key that does not exist yet | Simplex partner support |
| Bridgeless | No plugin | The partner API cannot express an incremental report | Bridgeless engineering |

## nexchange

The plugin ships in this branch, descended from
[#217](https://github.com/EdgeApp/edge-reports-server/pull/217). Two ports of it
existed for a while; this branch carries one copy, so there is nothing left to
deduplicate.

Two fixes found while porting are applied here, and are worth carrying into any
other surviving copy:

- Use `asMaybe` rather than `asOptional(asEither(asString, asNull), null)` on the
nullable response fields. `asOptional` still throws on an unexpected type, so
one odd row aborts the whole page, exhausts the retries and stalls the window.
- Treat a zero contract address (`0x000…0`) as the chain's native asset. Without
it `createTokenId` mints a non-null tokenId for the gas asset and mis-routes
its rates and volume. Banxa and Moonpay special-case this the same way.

The remaining blocker is credentials, and it is independent of whose code lands.
The key in `env.json` (`NEXCHANGE_INIT.apiKey`) authenticates but is not
authorised for the reporting resource:

```
GET https://api.n.exchange/en/api/v1/audit-orders
-> HTTP 403 {"detail":"API key is not authorised for this resource"}
```

A 403 rather than a 401 is the point: the key is recognised, the scope is
missing. There is no self-serve key console.

**Ask:** email support@n.exchange or the account manager for a reporting-scoped
key, and ask them to confirm the exact header and path for audit-orders. The
public v1/v2 OpenAPI specs document `Authorization: ApiKey <key>` and do not
mention `x-api-key` at all, while the ported plugin sends `x-api-key`, so the
header is worth confirming in the same message.

## Simplex

The shipped plugin is wrong in three independent ways, and only the first two can
be fixed without a key.

**Host and path.** `turnkey.api.simplex.com/transactions` is not the reporting
API any more. The current one is `/reporting/v1/payments`, and it is not on the
turnkey host:

```
GET https://turnkey.api.simplex.com/reporting/v1/payments
-> HTTP 403 {"message":"Missing Authentication Token"} # API Gateway for "no such route"

GET https://sandbox.test-simplexcc.com/reporting/v1/payments
-> HTTP 401 {"message":"Authorization header is missing"} # route exists, wants auth
```

**Auth scheme.** The plugin sends `X-API-KEY`. The reporting API ignores that
header entirely and wants `Authorization: ApiKey <key>`. The error text
distinguishes the two cases exactly:

```
-H 'x-api-key: <anything>' -> 401 "Authorization header is missing"
-H 'Authorization: Bearer <anything>' -> 401 "Authorization Apikey has invalid format"
-H 'Authorization: <anything>' -> 401 "Authorization Apikey has invalid format"
-H 'Authorization: ApiKey <anything>' -> 401 "Invalid API key"
```

Only the last one gets past format validation to key validation, which is what
identifies it as the correct scheme.

**Credential.** This is the blocker. `env.json` holds only the publishable
checkout credentials (`PLUGIN_API_KEYS.simplex.publicKey`, a `pk_live_` value,
plus the `jwtTokenProvider` flow). Reporting needs a separate server-side
partner key that Simplex issues on request and locks to a source IP. There is no
dashboard that mints one.

The plugin is not rewritten here because the response body has never been seen.
Writing cleaners for an unobserved payload would be inventing the schema, and a
half-migrated plugin is worse than the current one.

**Ask:** request a server-side reporting ApiKey from Simplex partner support,
supply the reports-server egress IP for their allowlist, and ask for one sample
`/reporting/v1/payments` response. The sample alone unblocks the rewrite; the key
unblocks verification. The IP lock also answers the key-exposure concern that got
Simplex reporting disabled in the first place.

## Bridgeless

Bridgeless is a public Cosmos-SDK L1, so there is no key to obtain. The
unauthenticated LCD at `https://rpc-api.node0.mainnet.bridgeless.com` does carry
Edge's attribution: Edge is referral 2 (`BRIDGELESS_INIT.referralId` in
`env.json`), and the module confirms it:

```
GET /cosmos/bridge/referrals/2
-> {"referral":{"id":2,"withdrawal_address":"bridge1rcu…","commission_rate":"0.66"}}

GET /cosmos/bridge/referrals/rewards/2
-> accrued commission per token, currently 4 tokens with a non-zero to_claim
```

So aggregate commission is readable today. Per-order reporting is not, for three
reasons that compound:

- **No timestamp.** A bridge transaction record carries `deposit_block` and
`deposit_chain_id` but no time of any kind. `StandardTx` requires `isoDate` and
`timestamp`, so every record would need a block lookup on its own origin chain
to be datable. The Cosmos tx index cannot supply it either: bridge state does
not move through user transactions, and sampled blocks contain none.
- **No referral filter.** `/cosmos/bridge/transactions` returns the whole set
(100,210 records) with no server-side filter, so attribution means scanning
everything and filtering `referral_id == 2` client-side.
- **No incremental cursor.** The store is hash-ordered, not append-ordered:
offsets 0, 50,000 and 100,000 return interleaved chains and unrelated block
heights. There is no watermark to resume from, so every run is a full rescan.

Together those make a plugin infeasible rather than merely expensive. A 20,000
record sample (20% of the store) contained exactly **one** record with
`referral_id == 2`, so the current shape would rescan 100,000+ untyped records
per cycle to recover a handful of Edge orders, and still could not date them.

**Ask:** ask Bridgeless engineering for any one of these, in preference order:
a `referral_id` filter plus a timestamp on `/cosmos/bridge/transactions`; an
append-ordered or height-keyed cursor so the scan can resume; or an off-chain
partner reporting endpoint. Also worth asking why on-chain `referral_id == 2` is
so rare while `/referrals/rewards/2` shows real accrued commission, since that
gap suggests attribution is recorded somewhere this endpoint does not expose.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"scripts": {
"build.lib": "sucrase -q -t typescript,imports,jsx -d ./lib ./src",
"build.types": "tsc",
"build.dist": "parcel build",
"build.dist": "parcel build && parcel build src/demoV2/index.html --dist-dir dist/v2 --public-url ./",
"clean": "rimraf lib dist .parcel-cache",
"configure": "configure",
"fix": "npm run lint -- --fix",
Expand All @@ -27,6 +27,7 @@
"stats": "node -r sucrase/register src/bin/partitionStats.ts",
"test": "mocha -r sucrase/register 'test/**/*.test.ts'",
"demo": "parcel serve src/demo/index.html",
"demo.v2": "parcel serve src/demoV2/index.html --dist-dir dist/v2 --public-url ./ -p 1235",
"lint": "eslint --ext .js,.jsx,.ts,.tsx ."
},
"lint-staged": {
Expand Down
44 changes: 41 additions & 3 deletions src/apiAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ interface DbTx {
orderId: string
depositCurrency: string
payoutCurrency: string
depositChainPluginId?: string
payoutChainPluginId?: string
timestamp: number
usdValue: number
revenueUsd?: number
}

interface Bucket {
start: number
usdValue: number
numTxs: number
revenueUsd: number
isoDate: string
currencyCodes: { [currencyCode: string]: number }
currencyPairs: { [currencyPair: string]: number }
chainedPairs: { [currencyPair: string]: number }
}

export const getAnalytics = (
Expand Down Expand Up @@ -49,8 +54,10 @@ export const getAnalytics = (
isoDate: monthStart.toISOString(),
usdValue: 0,
numTxs: 0,
revenueUsd: 0,
currencyCodes: {},
currencyPairs: {}
currencyPairs: {},
chainedPairs: {}
})
m++
monthStart = new Date(Date.UTC(y, m, 1, 0))
Expand All @@ -66,8 +73,10 @@ export const getAnalytics = (
isoDate: dayStart.toISOString(),
usdValue: 0,
numTxs: 0,
revenueUsd: 0,
currencyCodes: {},
currencyPairs: {}
currencyPairs: {},
chainedPairs: {}
})
d++
dayStart = new Date(Date.UTC(y, m, d, 0))
Expand All @@ -83,8 +92,10 @@ export const getAnalytics = (
isoDate: hourStart.toISOString(),
usdValue: 0,
numTxs: 0,
revenueUsd: 0,
currencyCodes: {},
currencyPairs: {}
currencyPairs: {},
chainedPairs: {}
})
h++
hourStart = new Date(Date.UTC(y, m, d, h))
Expand Down Expand Up @@ -160,6 +171,8 @@ const bucketAdder = (bucket: Bucket, tx: DbTx): void => {
bucket.numTxs++
// usdValue
bucket.usdValue += tx.usdValue != null ? tx.usdValue : 0
// reported revenue (partners that do not report one contribute 0)
bucket.revenueUsd += tx.revenueUsd != null ? tx.revenueUsd : 0
// currencyCode
if (bucket.currencyCodes[tx.depositCurrency] == null) {
bucket.currencyCodes[tx.depositCurrency] = 0
Expand All @@ -177,4 +190,29 @@ const bucketAdder = (bucket: Bucket, tx: DbTx): void => {
bucket.currencyPairs[currencyPair] = 0
}
bucket.currencyPairs[currencyPair] += tx.usdValue != null ? tx.usdValue : 0
const chainedPair = chainedPairKey(tx)
if (bucket.chainedPairs[chainedPair] == null) {
bucket.chainedPairs[chainedPair] = 0
}
bucket.chainedPairs[chainedPair] += tx.usdValue != null ? tx.usdValue : 0
}

export function assetChainKey(
currency: string,
chainPluginId?: string
): string {
if (chainPluginId == null || chainPluginId === '') return currency
return `${currency}@${chainPluginId}`
}

export function chainedPairKey(tx: {
depositCurrency: string
payoutCurrency: string
depositChainPluginId?: string
payoutChainPluginId?: string
}): string {
return `${assetChainKey(
tx.depositCurrency,
tx.depositChainPluginId
)}>${assetChainKey(tx.payoutCurrency, tx.payoutChainPluginId)}`
}
Loading
Loading