@dusk-network/app-core 0.4.0 → 0.4.1

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.4.1]
6
+
7
+ ### Changed
8
+
9
+ - `/duskevm-withdrawals`: a withdrawal's receipt is kept in `cache` once its DuskEVM block is final, and read once
10
+
11
+ ### Fixed
12
+
13
+ - `/bridge`: a memo of bytes that aren't text (`memoBinary`) gives no payout address, and names no migration
14
+
5
15
  ## [0.4.0]
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -206,8 +206,8 @@ arrivalsOf(arrivals); // Map { hash => { hash: "0x029e…", at: 1793080333000 }
206
206
  - 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.
207
207
  - 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.
208
208
  - 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 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.
209
- - 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.
210
- - 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 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.
209
+ - 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, as text, 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.
210
+ - 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, as text: a memo of bytes that aren't text gives none), 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 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.
211
211
  - With the archive, each transfer reads only its own hours of its account's history (its sender's for a deposit, the route's account's for a transfer out), further back while equal ones before it run on, and only once the archive lists the transfer there. A call reads 4 pages of it (`HISTORY_PAGES`) for each: a transfer whose history is longer waits, and the next call reads on from its `scan`.
212
212
  - A route without its payout account or address in the network's file pays out nothing that can be told apart: `no-route`.
213
213
  - An RPC search starts at a block from before the transfer, 10 minutes before it as the chain's pace says, checked by its time and stepped back until it is, and reads 8 windows at most: of 5,000 blocks, twice as many after each one the RPC answers whole, up to 100,000, and fewer where it asks for it, the width kept in the search's `scan` from call to call. A call reads a chain's head and pace once for all its searches. Once a deposit's windows are over and read, a search without its sender's history reads no further. It reads each block more than once: its window at the head reaches back 1,000 blocks, so the newest blocks are read again on each call, and once it has reached the head with a transfer not arrived, it reads its blocks again from the first, a pass at a time with the windows a call has left (one at least while a pass is under way, so 9 where the search ahead takes all 8): the first a minute later, each one after as long again as it has been since it reached the head. Where a later equal transfer, still without its credit, could have had the credit an earlier one takes, the earlier one waits until the blocks are read again 8 times since the credit was found, one pass after the other. 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. The block times the app keeps (`blockTimes`) are a chain's: for transfers out, a cache a chain by its chain ID, or one cache where the network's service routes are all on one chain.
@@ -258,7 +258,7 @@ const claimCall = await withdrawals.claimCall(txHash, { submitter: account }); /
258
258
  ### Reads and caches
259
259
 
260
260
  - 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.
261
- - 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.
261
+ - What a deployment doesn't change is read once: each game's hub, each game type's hub, each hub's clock limit, and a withdrawal's receipt once its DuskEVM block is final. A receipt is read after DuskEVM's finalized block, one read for the receipts read at the same time, and kept when its block is at or under that one; one that isn't final yet, or isn't a withdrawal's, is read again each time. `cache`, a `Map` of text by default, is where they're kept: an app can keep them across sessions.
262
262
  - 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.
263
263
 
264
264
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dusk-network/app-core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
package/src/bridge.d.ts CHANGED
@@ -409,6 +409,8 @@ export type HistoryLike = {
409
409
  /** Of `value`, a gas refund paid to another account than the sender. */
410
410
  refund?: bigint | string | null;
411
411
  memo: string;
412
+ /** True when `memo` is bytes that aren't text, given as 0x hex: no address or hash is read from it. */
413
+ memoBinary?: boolean;
412
414
  payer: boolean;
413
415
  timestamp?: number | null;
414
416
  success?: boolean | null;
@@ -433,7 +435,7 @@ export type ServiceTransferFacts = {
433
435
  /** What the route's account received: known only where no gas was left to refund; null otherwise. */
434
436
  amount: bigint | null;
435
437
  fee: bigint;
436
- /** The address its memo gives, exactly as the bridge reads it; null when it isn't one. */
438
+ /** The address its memo gives, exactly as the bridge reads it; null when it isn't one, or the memo isn't text. */
437
439
  to: string | null;
438
440
  at: number | null;
439
441
  height: number | null;
@@ -444,16 +446,18 @@ export type ServiceTransferFacts = {
444
446
  /**
445
447
  * A transfer the account sent to a service route's account, as its entry
446
448
  * says it; null for any other entry. The memo is read as the route's bridge
447
- * reads it: 0x and 40 hex digits, any case, nothing around them.
449
+ * reads it: 0x and 40 hex digits, any case, nothing around them, as text. A
450
+ * memo of bytes that aren't text (`memoBinary`) gives no address.
448
451
  */
449
452
  export declare function serviceTransferOf(entry: HistoryLike, network: NetworkRef): ServiceTransferFacts | null;
450
453
 
451
454
  /**
452
455
  * A payout of a migration, from its entry: a transfer the account received
453
456
  * from a migration route's payout account, with a transaction hash as the
454
- * memo, of its value less any gas `refund` paid to the account with it. Its
455
- * route is null where that account pays out more than one route. Null for
456
- * any other entry, and on a route without its payout account.
457
+ * memo as text, of its value less any gas `refund` paid to the account with
458
+ * it. Its route is null where that account pays out more than one route.
459
+ * Null for any other entry, a memo of bytes that aren't text included, and
460
+ * on a route without its payout account.
457
461
  */
458
462
  export declare function migrationPayoutOf(entry: HistoryLike, network: NetworkRef): MigrationPayout | null;
459
463
 
package/src/bridge.js CHANGED
@@ -1278,19 +1278,24 @@ function exactJson(text) {
1278
1278
  return JSON.parse(text.replace(TOKEN, (match, string) => string ?? (/^-?\d+$/.test(match) && !Number.isSafeInteger(Number(match)) ? `"${match}"` : match)));
1279
1279
  }
1280
1280
 
1281
- const utf8 = new TextDecoder();
1281
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
1282
1282
 
1283
1283
  /**
1284
- * A memo's bytes, given as hex, as text, read as the route's bridge reads
1285
- * them: as UTF-8, bytes that aren't UTF-8 becoming the replacement
1286
- * character; "" for none.
1284
+ * A memo's bytes, given as hex, as UTF-8 text; "" for none, and for bytes
1285
+ * that aren't UTF-8: an address or a hash is read from text only.
1287
1286
  *
1288
1287
  * @param {unknown} hex
1289
1288
  */
1290
1289
  function memoOf(hex) {
1291
1290
  const text = String(hex ?? "").replace(/^0x/i, "");
1292
1291
 
1293
- return /^(?:[0-9a-fA-F]{2})+$/.test(text) ? utf8.decode(Uint8Array.from(text.match(/../g) ?? [], (byte) => parseInt(byte, 16))) : "";
1292
+ if (!/^(?:[0-9a-fA-F]{2})+$/.test(text)) return "";
1293
+
1294
+ try {
1295
+ return utf8.decode(Uint8Array.from(text.match(/../g) ?? [], (byte) => parseInt(byte, 16)));
1296
+ } catch {
1297
+ return "";
1298
+ }
1294
1299
  }
1295
1300
 
1296
1301
  /**
@@ -2451,8 +2456,9 @@ export function serviceTransferOf(entry, network) {
2451
2456
  const spent = luxOf(entry.gasSpent);
2452
2457
  const price = luxOf(entry.gasPrice);
2453
2458
  const amount = limit !== null && spent !== null && price !== null && (limit - spent) * price <= 0n ? value : null;
2454
- // The address the memo gives, exactly as the bridge reads it.
2455
- const memo = typeof entry.memo === "string" ? entry.memo : "";
2459
+ // The address the memo gives, exactly as the bridge reads it: from text.
2460
+ // A memo of bytes that aren't text, which /history gives as 0x hex, gives none.
2461
+ const memo = typeof entry.memo === "string" && entry.memoBinary !== true ? entry.memo : "";
2456
2462
  const to = EVM_ADDRESS.test(memo) ? memo : null;
2457
2463
  const problem = entry.success === false ? "failed" : value <= route.feeLux ? "under-fee" : to === null ? "no-address" : null;
2458
2464
 
@@ -2470,9 +2476,8 @@ export function migrationPayoutOf(entry, network) {
2470
2476
  const { at, hash, height, value } = factsOf(entry);
2471
2477
  // What the transfer paid: a gas refund paid to the account with it aside.
2472
2478
  const refund = entry?.refund === undefined || entry.refund === null ? 0n : luxOf(entry.refund);
2473
- const memo = String(entry?.memo ?? "")
2474
- .trim()
2475
- .toLowerCase();
2479
+ // The migration's hash, as text: a memo of bytes that aren't text names none.
2480
+ const memo = entry?.memoBinary === true ? "" : String(entry?.memo ?? "").trim().toLowerCase();
2476
2481
 
2477
2482
  if (!paying.length || entry.success === false || !hash || height === null || value === null || refund === null || refund > value || !EVM_HASH.test(memo)) {
2478
2483
  return null;
@@ -359,7 +359,11 @@ export type WithdrawalsOptions = {
359
359
  maxBatch?: number;
360
360
  /** How many of the newest games a proof is looked for among: 64 by default, and for anything but a positive whole number. */
361
361
  gameSearchDepth?: number | bigint;
362
- /** Where the deployment's fixed facts are kept: each game's hub, each game type's hub, each hub's clock limit. */
362
+ /**
363
+ * Where the deployment's fixed facts are kept: each game's hub, each game
364
+ * type's hub, each hub's clock limit, and each withdrawal's receipt once
365
+ * its DuskEVM block is final.
366
+ */
363
367
  cache?: FactCache;
364
368
  /** The clock, in milliseconds. */
365
369
  now?: () => number;
@@ -368,7 +372,14 @@ export type WithdrawalsOptions = {
368
372
  /** A network's withdrawals, read on its Dusk node and DuskEVM. */
369
373
  export type Withdrawals = {
370
374
  readonly network: Network;
371
- /** A DuskEVM transaction's withdrawal, from its receipt; refuses as `parseWithdrawalReceipt` does. */
375
+ /**
376
+ * A DuskEVM transaction's withdrawal, from its receipt; refuses as
377
+ * `parseWithdrawalReceipt` does. The receipt is read after DuskEVM's
378
+ * finalized block, which the receipts read at the same time share, and
379
+ * kept in `cache` once its block is at or under that one: a final
380
+ * receipt is read once. One that isn't final yet, or isn't a
381
+ * withdrawal's, is read again each time.
382
+ */
372
383
  receipt(txHash: string): Promise<ReceiptWithdrawal>;
373
384
  /**
374
385
  * Where a withdrawal is, for `submitter` (a Dusk account, or its
@@ -773,6 +773,20 @@ function evmRpc(client, net) {
773
773
  };
774
774
  }
775
775
 
776
+ /**
777
+ * A block number as DuskEVM's RPC gives it, a hex quantity, or as a whole
778
+ * number; null for anything else.
779
+ *
780
+ * @param {unknown} value
781
+ * @returns {bigint | null}
782
+ */
783
+ function blockNumberOf(value) {
784
+ if (typeof value === "bigint") return value >= 0n ? value : null;
785
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
786
+
787
+ return typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value) ? BigInt(value) : null;
788
+ }
789
+
776
790
  // ---------------------------------------------------------------------------
777
791
  // The bridge's contracts on Dusk
778
792
  // ---------------------------------------------------------------------------
@@ -1223,13 +1237,137 @@ export function createWithdrawals(options) {
1223
1237
  ]).then(([status, blacklisted, proper, respected]) => ({ blacklisted, proper, respected, status }));
1224
1238
 
1225
1239
  /**
1240
+ * Where a final receipt is kept: by the network, its Portal and the
1241
+ * transaction's hash, so another network's or deployment's stays apart.
1242
+ *
1243
+ * @param {string} hash - 0x and 64 lower-case hex digits
1244
+ */
1245
+ const receiptKey = (hash) => `receipt:${net.chainId}:${contracts.portal}:${hash}`;
1246
+
1247
+ /** @type {Promise<bigint | null> | null} */
1248
+ let finalizing = null;
1249
+
1250
+ /**
1251
+ * DuskEVM's finalized block's height, or null where it can't be read;
1252
+ * the receipts read at the same time share one read.
1253
+ *
1254
+ * @returns {Promise<bigint | null>}
1255
+ */
1256
+ function finalizedHeight() {
1257
+ if (!finalizing) {
1258
+ const reading = Promise.resolve()
1259
+ .then(() => rpc("eth_getBlockByNumber", ["finalized", false]))
1260
+ .then(
1261
+ (block) => blockNumberOf(block?.number),
1262
+ () => null
1263
+ );
1264
+
1265
+ finalizing = reading;
1266
+ reading.then(() => {
1267
+ if (finalizing === reading) finalizing = null;
1268
+ });
1269
+ }
1270
+
1271
+ return finalizing;
1272
+ }
1273
+
1274
+ /** @type {Map<string, Promise<any>>} */
1275
+ const receiptReads = new Map();
1276
+
1277
+ /**
1278
+ * The withdrawal a kept receipt records, as when it was kept; null when
1279
+ * none is kept, or what is kept isn't this transaction's withdrawal.
1280
+ *
1281
+ * @param {string} hash
1282
+ * @returns {ReceiptWithdrawal | null}
1283
+ */
1284
+ function keptReceipt(hash) {
1285
+ try {
1286
+ const text = cache.get(receiptKey(hash));
1287
+
1288
+ if (typeof text !== "string" || !text) return null;
1289
+
1290
+ const withdrawal = parseWithdrawalReceipt(net, JSON.parse(text));
1291
+
1292
+ return withdrawal.transactionHash === hash ? withdrawal : null;
1293
+ } catch {
1294
+ return null;
1295
+ }
1296
+ }
1297
+
1298
+ /**
1299
+ * Keeps a receipt that can't change any more: a withdrawal's, of this
1300
+ * transaction, in a block at or under `final`, the finalized height read
1301
+ * before the receipt was. A failed or missing receipt, or one that isn't
1302
+ * a withdrawal's, isn't kept. Kept as the RPC gave it, once its text
1303
+ * reads back as the same withdrawal.
1304
+ *
1305
+ * @param {string} hash
1306
+ * @param {any} raw
1307
+ * @param {bigint} final
1308
+ */
1309
+ function keepIfFinal(hash, raw, final) {
1310
+ try {
1311
+ const withdrawal = parseWithdrawalReceipt(net, raw);
1312
+ const block = blockNumberOf(raw?.blockNumber);
1313
+
1314
+ if (block === null || block !== withdrawal.blockNumber || block > final || withdrawal.transactionHash !== hash) return;
1315
+
1316
+ const text = JSON.stringify(raw);
1317
+ const again = parseWithdrawalReceipt(net, JSON.parse(text));
1318
+
1319
+ if (again.withdrawalHash !== withdrawal.withdrawalHash || again.blockNumber !== block || again.transactionHash !== hash) return;
1320
+
1321
+ cache.set(receiptKey(hash), text);
1322
+ } catch {
1323
+ // Not kept: it's read again next time.
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * A transaction's receipt as DuskEVM gives it, read after DuskEVM's
1329
+ * finalized block: a receipt read once its block is final is the final
1330
+ * chain's, and is kept. The callers asking for one receipt at the same
1331
+ * time share one read.
1332
+ *
1333
+ * @param {string} hash
1334
+ * @returns {Promise<any>}
1335
+ */
1336
+ function readReceipt(hash) {
1337
+ let reading = receiptReads.get(hash);
1338
+
1339
+ if (!reading) {
1340
+ reading = (async () => {
1341
+ const final = await finalizedHeight();
1342
+ const raw = await rpc("eth_getTransactionReceipt", [hash]);
1343
+
1344
+ if (final !== null) keepIfFinal(hash, raw, final);
1345
+
1346
+ return raw;
1347
+ })();
1348
+ receiptReads.set(hash, reading);
1349
+ reading.then(
1350
+ () => receiptReads.delete(hash),
1351
+ () => receiptReads.delete(hash)
1352
+ );
1353
+ }
1354
+
1355
+ return reading;
1356
+ }
1357
+
1358
+ /**
1359
+ * A transaction's withdrawal, from its receipt: the one kept once its
1360
+ * block was final, else the one DuskEVM gives now.
1361
+ *
1226
1362
  * @param {string} txHash
1227
1363
  * @returns {Promise<ReceiptWithdrawal>}
1228
1364
  */
1229
1365
  async function receipt(txHash) {
1230
1366
  if (typeof txHash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) throw new WithdrawalError("bad-hash");
1231
1367
 
1232
- return parseWithdrawalReceipt(net, await rpc("eth_getTransactionReceipt", [txHash.toLowerCase()]));
1368
+ const hash = txHash.toLowerCase();
1369
+
1370
+ return keptReceipt(hash) ?? parseWithdrawalReceipt(net, await readReceipt(hash));
1233
1371
  }
1234
1372
 
1235
1373
  /**