@dusk-network/app-core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this package are listed here. It follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.3.0]
6
+
7
+ ### Added
8
+
9
+ - `/duskevm-withdrawals`: a DuskEVM withdrawal's start, finding it again, its status and claim date, and its proof's and claim's calls, checked before they're handed over to sign
10
+
11
+ ### Fixed
12
+
13
+ - `/bridge`: a migration is paid by its own route's payout account also where one account pays out both routes (a payout of one not listed then has a null `route`), and a payout read from an entry leaves out a gas refund paid with it, which `/history` entries now tell apart as `refund`
14
+
5
15
  ## [0.2.0]
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -31,8 +31,9 @@ The entry points are the same on both, under each registry's name: `@dusk-networ
31
31
  | `@dusk-network/app-core/shielded` | A profile's shielded history from the notes its shielded sync keeps, each block asked for among kept decoys (`shieldedHistory`); its entries as facts (`shieldedEntries`); the blocks at the notes' heights (`fetchShieldedBlocks`); the note tree's heights and the decoys drawn from them (`noteTreeHeights`, `readNoteHeights`, `createLeafHeightTap`, `pickHeights`, `leafInfo`) |
32
32
  | `@dusk-network/app-core/activity` | An account's activity as rows of facts, one per transaction, newest first, from its public and shielded history and the wallet's own sends (`activityRows`), and the sends the chain's rows now say all of (`settleSends`) |
33
33
  | `@dusk-network/app-core/bridge` | The other side of the bridge's routes: where a deposit to DuskEVM arrived (`depositArrivals`), a migration's payout on Dusk (`migrationPayouts`), and where a transfer out to BNB Smart Chain arrived (`serviceArrivals`); the history's entries as those transfers (`depositOf`, `migrationPayoutOf`, `serviceTransferOf`); and the arrivals as `/activity` takes them (`arrivalsOf`) |
34
+ | `@dusk-network/app-core/duskevm-withdrawals` | A DuskEVM withdrawal's life, signing aside: its start on DuskEVM (`withdrawalCall`), finding an address's withdrawals (`syncWithdrawals`, `explorerWithdrawals`, `withdrawalsByNonce`), and on Dusk where each is, its proof and its claim, ready for the app's wallet to sign (`createWithdrawals`) |
34
35
 
35
- Every entry point has its `.d.ts`. The one runtime dependency is `@dusk-network/networks`.
36
+ Every entry point has its `.d.ts`. The one runtime dependency is `@dusk-network/networks`. `/duskevm-withdrawals` also takes [viem](https://viem.sh) (`^2.55.2`), an optional peer: an app installs it to use that entry, and no other entry imports it.
36
37
 
37
38
  ## History
38
39
 
@@ -58,7 +59,7 @@ const { entries, complete } = await history.read({ limit: 50 });
58
59
  - `network` is the node's network or its chain ID, for the DuskEVM bridge's calls, the bridge's routes and Sozu. A node of a network there's no file for passes none.
59
60
  - `fillDetails(entries)` reads the details of entries shown before a run gave them any.
60
61
 
61
- An entry is facts, not text: `direction` ("in", "out", "self"), `kind` (`transfer`, `shield`, `unshield`, `contract_call`, `stake`, `unstake`, `withdraw_reward`), `counterparty`, `value` and `fee` in lux (a gas refund paid to another account counts in the sender's `value`), `memo`, and with its details its time, outcome, gas and call. A call to DuskEVM's bridge or Portal has `bridge`, what it did (`decodeBridgeCall`): a deposit's amount and DuskEVM address, or a withdrawal's proof or claim, its hash, whom it pays and its nonce. A transfer with one of the bridge's accounts has its `route` (`routeForAccount`), and a stake or an unstake with Sozu `sozu: true` (`isSozuCall`, with the pools the hub named in `read({ pools })`).
62
+ An entry is facts, not text: `direction` ("in", "out", "self"), `kind` (`transfer`, `shield`, `unshield`, `contract_call`, `stake`, `unstake`, `withdraw_reward`), `counterparty`, `value` and `fee` in lux (a gas refund paid to another account counts in the sender's `value` and in that account's, told apart as `refund`), `memo`, and with its details its time, outcome, gas and call. A call to DuskEVM's bridge or Portal has `bridge`, what it did (`decodeBridgeCall`): a deposit's amount and DuskEVM address, or a withdrawal's proof or claim, its hash, whom it pays and its nonce. A transfer with one of the bridge's accounts has its `route` (`routeForAccount`), and a stake or an unstake with Sozu `sozu: true` (`isSozuCall`, with the pools the hub named in `read({ pools })`).
62
63
 
63
64
  ### The store
64
65
 
@@ -201,7 +202,7 @@ arrivalsOf(arrivals); // Map { hash => { hash: "0x029e…", at: 1793080333000 }
201
202
  - A transfer's time is its Dusk block's, never the device's clock. One without it yet isn't looked for. One sent more than a week ago (`waitMs`) is unknown, and isn't asked about.
202
203
  - A deposit is followed on DuskEVM's explorer (`via: "explorer"`, the default): the recipient's incoming calls, newest first, read once for all its deposits, back past the oldest, 5 pages at most. Those calls don't say who deposited, so a payment is a deposit's only when it can't be another's: within 2 minutes either side of its block, once those minutes are over, with no other payment of its amount there (a failed one counts) that isn't another given deposit's. Nothing is asked before then.
203
204
  - On DuskEVM's RPC (`via: "rpc"`), a deposit is found by DuskEVM's bridge event from its sender's DuskEVM address. With `/history`'s archive, each sender's equal deposits in the hours before its own are read from its history and paid first; a history too long to read, or a deposit there whose call can't be read, leaves the order unclear. Without it, the RPC's credits are taken as the explorer's are. A deposit given without its sender takes no credit from the sender of another equal one given.
204
- - A migration is paid by a transfer to the account from its route's payout account, with the migration's transaction hash as the memo, of exactly its amount: from the account's entries, or from the archive's memo index, which gives each payout's own amount. One from that account of another amount leaves it unsure, not waiting: an entry's value holds any gas refund paid to the account. With `entries`, the payouts there of migrations the app didn't list come too (`unlisted`).
205
+ - A migration is paid by a transfer to the account from its route's payout account, with the migration's transaction hash as the memo, of exactly its amount: from the account's entries, less any `refund` paid to the account with it, or from the archive's memo index, which gives each payout's own amount. One from that account of another amount leaves it unsure, not waiting: the value of an entry an earlier version kept holds any gas refund paid to the account. With `entries`, the payouts there of migrations the app didn't list come too (`unlisted`), each with its `payer`; where one account pays out more than one route, a payout of a migration not listed doesn't say which: its `route` is null.
205
206
  - A transfer out is read from the chain: what the route's account received, the address its memo gives as the route's bridge reads it (0x and 40 hex digits, any case, nothing around them), and its block. It arrives with the DUSK token's `Transfer` from its route's payout address to that address, of what the account received less the fee. The account's history lists every transfer that named the same address, in any case: equal ones in the hours before (a given one's by its own fee) are paid first, and one that can't be read, or a history cut short, leaves the order unclear. One that pays nothing out is `no-payout`, with its `problem`. `serviceTransferOf` gives what left the account (`sent`), and what the account received (`amount`) only where no gas was left to refund: an entry's value holds any refund paid to another account.
206
207
  - A route without its payout account or address in the network's file pays out nothing that can be told apart: `no-route`.
207
208
  - An RPC search starts at a block from before the transfer, estimated from the chain's pace and checked by its time, and reads 8 windows of 5,000 blocks at most, narrower where the RPC asks for it. A result that isn't arrived says where the next search starts, with what it searched for and the credits found so far (`scan`): the app keeps it with the arrivals it was made with, and passes both back. A search for another sender or amount starts afresh.
@@ -210,6 +211,51 @@ A result is facts: `arrived`, with the other side's transaction, its block's tim
210
211
 
211
212
  `activityRows` of `/activity` takes the deposits that arrived as its `arrivals`: `arrivalsOf` gives them by the deposit's hash, the DuskEVM transaction that paid it and when, and the deposit's row says where it arrived. Its `claims` are DuskEVM withdrawals', which this entry point doesn't read.
212
213
 
214
+ ## DuskEVM withdrawals
215
+
216
+ ```js
217
+ import { createWithdrawals, withdrawalCall } from "@dusk-network/app-core/duskevm-withdrawals";
218
+
219
+ // The start, for the app's EVM wallet to send on DuskEVM.
220
+ const start = withdrawalCall(chainId, { amountWei, sender: evmAddress, to: duskAccount }); // { to, data, value }
221
+
222
+ const withdrawals = createWithdrawals({
223
+ network: chainId,
224
+ nodeUrl,
225
+ fetch: createMulticallFetch({ nodeUrl, multicallId }), // the reads one task starts go as one query
226
+ drivers: { optimismPortal: () => loadDriver(portalDriver), disputeGameFactory: () => loadDriver(factoryDriver) },
227
+ duskEvm: publicClient, // a viem client, or { url, fetch }
228
+ });
229
+
230
+ const status = await withdrawals.status(txHash, { submitter: account, proofGame });
231
+ // { stage: "proven_waiting", reason: "game-in-progress", readyAt: 1793589639n, readyAtExact: false, facts: { amountWei, recipient, … }, … }
232
+
233
+ const { call, claim } = await withdrawals.prepareProof(txHash, { submitter: account });
234
+ // claim: when it can be claimed if proved now; call: { contractId, fnName, fnArgs, deposit: 0n, gasLimit, decoded }
235
+ const claimCall = await withdrawals.claimCall(txHash, { submitter: account }); // once `status` says "ready_to_finalize"
236
+ ```
237
+
238
+ - Only DUSK withdrawals on the bridge's route count, as Dusk's L1 bridge pays them out (`decodeWithdrawal` of `@dusk-network/networks/duskevm`): to Dusk's messenger, relaying DuskEVM's bridge to Dusk's, paying a Dusk account, or a contract only whole lux at the address DuskEVM gives it, and no one past a u64 of lux. Anything else is refused, for its status, its proof and its claim (`WithdrawalError`, `code: "not-a-withdrawal"`).
239
+ - `stage` is where the withdrawal is for the account whose proof counts (`submitter`, a Dusk account or its Portal address; its recipient's account when none is given): "waiting_for_output", "ready_to_prove", "proven_waiting", "ready_to_finalize" or "finalized". `reason` says why it waits. A claim date is worked out as the Portal checks a claim (`claimFrom`), counted from Dusk's latest block, and is exact once the proof's game has resolved; `readyAt` is absent when the game can't be read now, so the date known so far stands. A claim is ready once the Portal takes it, its game has resolved for the defender, and its date has come: never while the game, the Portal's blacklist or the anchor state registry's word on it can't be read, nor on the Portal's own check alone, which may take just the proof and its game. A proof whose game the Portal refuses (blacklisted, retired, no longer respected, or won by the challenger) can't be claimed, whatever that check says (`proofRejected`, "proof-rejected"). `provenByOther` says another account holds a proof that stands, which this one can't claim with.
240
+ - `proofGame`, the game the last status found the proof against, puts that game's reads in the same query as the Portal's: a check is one query.
241
+ - A proof goes against a game that covers the withdrawal and has already resolved for its proposal, where the anchor state registry calls it proper and respected and it came after the respected game type last changed, so its claim date is exact when it's made; otherwise against the newest game that covers it, among the newest 64 (`gameSearchDepth`), that the Portal takes a proof against: a game the Portal blacklisted, the registry calls improper or not respected, or the challenger won is passed over for an older one, as is one whose standing can't be read. Where none of those is one the Portal takes, a status offers a proof against one that has resolved, as `prepareProof` finds it, to an account that never proved and to one whose proof the Portal refuses. A proof isn't prepared for a claimed withdrawal, nor over a proof of the account's that stands: it would cost its fee for nothing. One the Portal refuses is made again against another game it takes.
242
+ - `prepareProof` and `claimCall` refuse what would do nothing or can't be made: "claimed", "proven", "no-proof", "not-ready" (with the status), and "gas" for a withdrawal whose own gas takes the limit over the app's bound (`gasLimitUpper`, 1,000,000,000 by default). The gas limit is 50M and the withdrawal's own gas, at least 1M; the price is the app's.
243
+ - A call is encoded by the Portal's data driver the app loaded, as the network's file names and pins it, and read back before it's handed over: its arguments must say exactly the withdrawal, the game, the output root proof and the proof's nodes they were built from, as Dusk Wallet reads them (`decoded`), or it's refused ("bad-call"). It sends no DUSK along; the app signs `fnArgs` as given, with no amount and no one paid but the account itself.
244
+ - `prepareProof` gives `at`, when it was prepared: an app that shows a proof's claim date sends that proof, for as long as it chooses.
245
+ - `claims(hashes, { submitter })` reads when the withdrawals an account proved can be claimed, by their hashes: claimed, ready, a date, none, or to prove again. Its map is `/activity`'s `claims`.
246
+ - `timing()` is how long a withdrawal takes: the Portal's two delays, and how often outputs come.
247
+
248
+ ### Finding withdrawals
249
+
250
+ - `syncWithdrawals({ address, duskEvm, network, state })` reads an address's withdrawals from the bridge's own `ETHBridgeInitiated` events, the newest final blocks first, a window of 100,000 blocks at a time (narrower where the RPC asks for it), 8 requests a run. It gives the app the state to keep, and the next run carries on below and reads only what is new above. Under a final head lower than the last run's, what it covered and found above it is dropped, and read again.
251
+ - `explorerWithdrawals({ address, network, fetch })` lists the address's own calls of the bridge on DuskEVM's explorer, each with the explorer's word for it (`explorerStage`), and `withdrawalsByNonce({ nonces, network, fetch })` finds the DuskEVM transactions of withdrawals known by the nonces their proofs and claims on Dusk carry.
252
+
253
+ ### Reads and caches
254
+
255
+ - The Portal and the dispute game factory are read with the app's drivers; each game's hub and the anchor state registry raw. Reads go to the node's contracts through the app's `fetch` (its Multicall for Dusk batcher), sent again twice at most when the node answers 429, 502, 503 or 504; a refusal carries the contract's own words. `maxBatch` is how many reads one query holds (32 by default), each game checked taking 4: its status, the Portal's blacklist, and the registry's two answers. A game's status is read on its own hub, which the factory names: a game type's hub can change, and the games made before stay where they were.
256
+ - What a deployment doesn't change is read once: each game's hub, each game type's hub, each hub's clock limit. `cache`, a `Map` of text by default, is where they're kept: an app can keep them across sessions.
257
+ - Dusk's latest block comes from `latestBlock`, or the node's GraphQL. Without it, a proved withdrawal's status fails: its date can't be worked out, and no claim is built.
258
+
213
259
  ## License
214
260
 
215
261
  MPL-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dusk-network/app-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared logic for the Dusk apps: public and shielded history, activity rows, bridge arrivals and DuskEVM withdrawals.",
5
5
  "license": "MPL-2.0",
6
6
  "type": "module",
@@ -25,6 +25,10 @@
25
25
  "types": "./src/bridge.d.ts",
26
26
  "default": "./src/bridge.js"
27
27
  },
28
+ "./duskevm-withdrawals": {
29
+ "types": "./src/duskevm-withdrawals.d.ts",
30
+ "default": "./src/duskevm-withdrawals.js"
31
+ },
28
32
  "./package.json": "./package.json"
29
33
  },
30
34
  "files": [
@@ -39,10 +43,23 @@
39
43
  "typecheck": "npx --yes --package=typescript@5.9.3 -- tsc -p test-types",
40
44
  "check:versions": "node scripts/check-versions.js",
41
45
  "check:jsr": "npx --yes jsr@0.14.3 publish --dry-run --allow-dirty",
46
+ "fetch:drivers": "node scripts/fetch-drivers.js",
42
47
  "smoke:history": "node scripts/smoke-history.js",
43
- "smoke:bridge": "node scripts/smoke-bridge.js"
48
+ "smoke:bridge": "node scripts/smoke-bridge.js",
49
+ "smoke:withdrawals": "node scripts/smoke-withdrawals.js"
44
50
  },
45
51
  "dependencies": {
46
52
  "@dusk-network/networks": "0.3.0"
53
+ },
54
+ "peerDependencies": {
55
+ "viem": "^2.55.2"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "viem": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "viem": "2.55.2"
47
64
  }
48
65
  }
package/src/bridge.d.ts CHANGED
@@ -301,8 +301,15 @@ export type Migration = {
301
301
  export type MigrationPayout = {
302
302
  /** The migration's transaction hash, from the memo, lower case. */
303
303
  hash: `0x${string}`;
304
- route: string;
305
- /** What it paid, in lux. */
304
+ /**
305
+ * Its route's ID; null where its payer pays out more than one route's
306
+ * migrations, as one account set for both chains does: its transfer
307
+ * doesn't say which.
308
+ */
309
+ route: string | null;
310
+ /** The Dusk account that paid it: its route's payout account. */
311
+ payer: string;
312
+ /** What it paid, in lux: a gas refund paid to the account with it aside. */
306
313
  amount: bigint;
307
314
  /** The payout's Dusk transaction. */
308
315
  payout: string;
@@ -332,9 +339,12 @@ export type MigrationPayoutOptions = {
332
339
  * Each migration's payout on Dusk, by the migration's hash (lower case), in
333
340
  * the order given; with `entries`, the payouts there of migrations not given
334
341
  * (`unlisted`: sent from another device, or before); and the requests it
335
- * took. Each payout pays one migration. A payout of the migration of another
336
- * amount leaves it unsure, not waiting: an entry's value holds any gas
337
- * refund paid to the account; the memo index gives each payout's own.
342
+ * took. Each payout pays one migration, of the route it was given with:
343
+ * from that route's payout account, even where that account pays out
344
+ * another route's too. A payout of the migration of another amount leaves
345
+ * it unsure, not waiting: the value of an entry an earlier version kept
346
+ * holds any gas refund paid to the account (`refund` tells it apart now);
347
+ * the memo index gives each payout's own.
338
348
  */
339
349
  export declare function migrationPayouts(
340
350
  options: MigrationPayoutOptions
@@ -348,6 +358,8 @@ export type HistoryLike = {
348
358
  kind: EntryKind;
349
359
  counterparty: string | null;
350
360
  value: bigint | string;
361
+ /** Of `value`, a gas refund paid to another account than the sender. */
362
+ refund?: bigint | string | null;
351
363
  memo: string;
352
364
  payer: boolean;
353
365
  timestamp?: number | null;
@@ -391,7 +403,9 @@ export declare function serviceTransferOf(entry: HistoryLike, network: NetworkRe
391
403
  /**
392
404
  * A payout of a migration, from its entry: a transfer the account received
393
405
  * from a migration route's payout account, with a transaction hash as the
394
- * memo. Null for any other entry, and on a route without its payout account.
406
+ * memo, of its value less any gas `refund` paid to the account with it. Its
407
+ * route is null where that account pays out more than one route. Null for
408
+ * any other entry, and on a route without its payout account.
395
409
  */
396
410
  export declare function migrationPayoutOf(entry: HistoryLike, network: NetworkRef): MigrationPayout | null;
397
411
 
package/src/bridge.js CHANGED
@@ -1725,6 +1725,17 @@ export async function serviceArrivals({
1725
1725
  // Migrations into Dusk
1726
1726
  // ---------------------------------------------------------------------------
1727
1727
 
1728
+ /**
1729
+ * The migration routes an account pays out. More than one where a
1730
+ * deployment has one account pay out both chains' migrations: then a
1731
+ * payout of it doesn't say which route it is on.
1732
+ *
1733
+ * @param {Network} net
1734
+ * @param {string} account
1735
+ * @returns {Route[]}
1736
+ */
1737
+ const paidOutBy = (net, account) => routes(net).filter((route) => route.kind === "migration" && Boolean(route.payoutAccount) && route.payoutAccount === account.trim());
1738
+
1728
1739
  /**
1729
1740
  * The payouts of migrations a transfer the archive lists holds for
1730
1741
  * `account`, read from its own events: from a migration route's payout
@@ -1744,12 +1755,12 @@ function payoutsIn(raw, account, net) {
1744
1755
 
1745
1756
  return (Array.isArray(raw?.events) ? raw.events : []).flatMap((/** @type {any} */ event) => {
1746
1757
  const data = event?.data;
1747
- const route = data && event.reverted !== true && event.topic === "moonlight" && String(event.target ?? "").toLowerCase() === TRANSFER_CONTRACT && data.receiver === account && typeof data.sender === "string" ? routeForAccount(net, data.sender, "in") : null;
1758
+ const paying = data && event.reverted !== true && event.topic === "moonlight" && String(event.target ?? "").toLowerCase() === TRANSFER_CONTRACT && data.receiver === account && typeof data.sender === "string" ? paidOutBy(net, data.sender) : [];
1748
1759
  const memo = memoOf(data?.memo).trim().toLowerCase();
1749
1760
  const amount = luxOf(data?.value);
1750
1761
 
1751
- return route?.kind === "migration" && route.payoutAccount && EVM_HASH.test(memo) && amount !== null
1752
- ? [{ amount, at: null, block, hash: /** @type {`0x${string}`} */ (memo), payout, route: route.id }]
1762
+ return paying.length && EVM_HASH.test(memo) && amount !== null
1763
+ ? [{ amount, at: null, block, hash: /** @type {`0x${string}`} */ (memo), payer: data.sender.trim(), payout, route: paying.length === 1 ? paying[0].id : null }]
1753
1764
  : [];
1754
1765
  });
1755
1766
  }
@@ -1811,22 +1822,22 @@ export async function migrationPayouts({ network, migrations, entries, archive,
1811
1822
  }
1812
1823
 
1813
1824
  // Each payout pays one migration: the migration its memo names, of the
1814
- // amount it paid, on the route whose payout account paid it.
1825
+ // amount it paid, from its route's payout account.
1815
1826
  const byBlock = [...candidates].sort((a, b) => a.block - b.block || (a.payout < b.payout ? -1 : 1));
1816
1827
 
1817
1828
  for (const item of [...payable].sort((a, b) => (a.at ?? Infinity) - (b.at ?? Infinity))) {
1818
1829
  if (results.has(item.hash)) continue;
1819
1830
 
1820
- const named = byBlock.filter((each) => !used.has(each.payout) && each.hash === item.hash && each.route === item.route?.id);
1831
+ const named = byBlock.filter((each) => !used.has(each.payout) && each.hash === item.hash && each.payer === item.route?.payoutAccount);
1821
1832
  const payout = named.find((each) => each.amount === item.amount);
1822
1833
 
1823
1834
  if (payout) {
1824
1835
  used.add(payout.payout);
1825
1836
  results.set(item.hash, { amount: payout.amount, at: payout.at, block: payout.block, hash: payout.payout, state: "arrived" });
1826
1837
  } else if (named.length) {
1827
- // Its payout, of another amount: an entry's value holds a gas refund
1828
- // paid to the account, which it doesn't tell apart, or another amount
1829
- // was paid. Not waiting: unsure.
1838
+ // Its payout, of another amount: another amount was paid, or the value
1839
+ // of an entry an earlier version kept holds a gas refund paid to the
1840
+ // account. Not waiting: unsure.
1830
1841
  results.set(item.hash, { reason: "unsure", state: "unknown" });
1831
1842
  } else {
1832
1843
  results.set(item.hash, item.at !== null && now - item.at > waitMs ? { reason: "expired", state: "unknown" } : { state: "waiting" });
@@ -1900,17 +1911,27 @@ export function serviceTransferOf(entry, network) {
1900
1911
  */
1901
1912
  export function migrationPayoutOf(entry, network) {
1902
1913
  const net = networkOf(network);
1903
- const route = entry?.kind === "transfer" && entry.direction === "in" && typeof entry.counterparty === "string" ? routeForAccount(net, entry.counterparty, "in") : null;
1914
+ const paying = entry?.kind === "transfer" && entry.direction === "in" && typeof entry.counterparty === "string" ? paidOutBy(net, entry.counterparty) : [];
1904
1915
  const { at, hash, height, value } = factsOf(entry);
1916
+ // What the transfer paid: a gas refund paid to the account with it aside.
1917
+ const refund = entry?.refund === undefined || entry.refund === null ? 0n : luxOf(entry.refund);
1905
1918
  const memo = String(entry?.memo ?? "")
1906
1919
  .trim()
1907
1920
  .toLowerCase();
1908
1921
 
1909
- if (!route || route.kind !== "migration" || !route.payoutAccount || entry.success === false || !hash || height === null || value === null || !EVM_HASH.test(memo)) {
1922
+ if (!paying.length || entry.success === false || !hash || height === null || value === null || refund === null || refund > value || !EVM_HASH.test(memo)) {
1910
1923
  return null;
1911
1924
  }
1912
1925
 
1913
- return { amount: value, at, block: height, hash: /** @type {`0x${string}`} */ (memo), payout: hash, route: route.id };
1926
+ return {
1927
+ amount: value - refund,
1928
+ at,
1929
+ block: height,
1930
+ hash: /** @type {`0x${string}`} */ (memo),
1931
+ payer: /** @type {string} */ (entry.counterparty).trim(),
1932
+ payout: hash,
1933
+ route: paying.length === 1 ? paying[0].id : null,
1934
+ };
1914
1935
  }
1915
1936
 
1916
1937
  /**