From 402748ea8af231b7a7de1ed935198cf0f02f53d9 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 11 Aug 2026 09:00:39 -0500 Subject: [PATCH 1/6] fix(swap-to-wire): claim the credited payout instead of waiting on a balance sysio.reserv::paywire now credits a claimable balance rather than pushing sysio.token::transfer. It settles inside the never-throw consensus dispatch chain, where a pushed transfer let the recipient's on_notify handler abort the delivery -- the chain runs notified receivers with no exception isolation. The flow polled the recipient's WIRE balance until a deadline, which can no longer become true: the payout sits in sysio.reserv custody until pulled. It now waits on the claimable balance and calls claimwire, so bridging into WIRE is settle-then-claim. WireClient gains claimWire / claimPay and their claimable-balance readers. Those read the tables raw rather than through the typed contract-table accessor, because the new tables do not reach the typed surface until @wireio/sdk-core publishes the regenerated SysioContractTypes. The other flows need no change: flow-swap-from-wire never exercises a revert, so refundwire is not on its path; flow-reserve-lifecycle's WIRE assertions cover the matchreserve escrow, which still pushes from the user; and flow-emissions-soak covers sysio.dclaim staker claims, which were already pull-based. --- .../src/clients/wire/WireClient.ts | 62 +++++++++++++++++++ .../src/SwapToWireScenario.ts | 13 +++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/cluster-tool/src/clients/wire/WireClient.ts b/packages/cluster-tool/src/clients/wire/WireClient.ts index 45a78c33..8f84d081 100644 --- a/packages/cluster-tool/src/clients/wire/WireClient.ts +++ b/packages/cluster-tool/src/clients/wire/WireClient.ts @@ -416,6 +416,68 @@ export class WireClient { return BigInt(whole) * 1_000_000_000n + BigInt(frac.padEnd(9, "0")) } + /** + * Pull `account`'s claimable WIRE from `sysio.reserv` — swap-to-WIRE payouts and swap-from-WIRE + * refunds. + * + * Those settlement paths credit a balance instead of transferring: `sysio.token::transfer` + * notifies its recipient, and the chain runs notified receivers with no exception isolation, so + * a pushed payout let the recipient abort the enclosing transaction — which for `refundwire` + * meant halting the epoch drain chain-wide. The claim carries the claimant's own authority, so a + * hostile recipient can only block itself. + * + * Throws when nothing is owed; check {@link getWireClaimable} first if that is not a failure. + */ + async claimWire(account: string, permission = "active") { + return this.invoke("sysio.reserv", "claimwire", { account }, [{ actor: account, permission }]) + } + + /** + * WIRE owed to `account` but not yet claimed, or 0n when there is no row. + * + * Raw `getTableRows` rather than the typed contract-table accessor + * (`prefer-typed-contract-table-accessors.md`) because `wireclaims` is new and does not reach + * the typed surface until `@wireio/sdk-core` publishes the regenerated `SysioContractTypes`. + * Switch to `getSysioContract(SysioContractName.reserv).tables.wireclaims.query()` once that + * version is released and this package's dependency is bumped. + */ + async getWireClaimable(account: string): Promise { + const { rows } = await this.getTableRows<{ balance: string | number }>({ + account: "sysio.reserv", + scope: "sysio.reserv", + table: "wireclaims", + lowerBound: account, + upperBound: account, + limit: 1 + }) + return rows.length === 0 ? 0n : BigInt(rows[0].balance) + } + + /** + * Pull `account`'s credited epoch pay from `sysio.system` — producer, standby, batch-operator and + * category-bucket shares. `payepoch` credits rather than transfers for the same reason as above: + * it runs inline from `sysio.epoch::advance`, which must never abort. + */ + async claimPay(account: string, permission = "active") { + return this.invoke("sysio", "claimpay", { account_name: account }, [ + { actor: account, permission } + ]) + } + + /** Epoch pay owed to `account` but not yet claimed, or 0n when there is no row. Raw table read + * for the same reason as {@link getWireClaimable}. */ + async getPayClaimable(account: string): Promise { + const { rows } = await this.getTableRows<{ balance: string | number }>({ + account: "sysio", + scope: "sysio", + table: "payclaims", + lowerBound: account, + upperBound: account, + limit: 1 + }) + return rows.length === 0 ? 0n : BigInt(rows[0].balance) + } + // Convenience getters delegate to the typed contract-table accessor // (prefer-typed-contract-table-accessors.md) — never a raw getTableRows. getOperators() { diff --git a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts index 3149c099..28cbf5a4 100644 --- a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts +++ b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts @@ -570,13 +570,19 @@ export class SwapToWireScenario extends FlowScenario { SwapToWireScenario.Output.recipient ), target = ctx.outputs.assert(SwapToWireScenario.Output.target) + // paywire CREDITS the recipient rather than transferring: it settles inside the + // never-throw consensus dispatch chain, where a pushed `sysio.token::transfer` would let + // the recipient's notify handler abort the delivery. So wait for the claimable balance, + // not the token balance — the latter stays at zero until the recipient pulls it. await pollUntil( - "recipient WIRE balance reaches the target", + "recipient claimable WIRE reaches the target", async () => - (await ctx.wire.getWireBalance(recipient.account)) >= target, + (await ctx.wire.getWireClaimable(recipient.account)) >= target, Constants.PayoutDeadlineMs, Constants.LongPollIntervalMs ) + // Bridging into WIRE is a two-step flow now: settle, then claim. + await ctx.wire.claimWire(recipient.account) // paywire pays `dst_amount` exactly, and since #550 `dst_amount` is // the depot's own quote — `split_wire_fee(gross).net` — not the // caller's `target_amount`. The fee is borne by the swapper, not by @@ -633,6 +639,9 @@ export class SwapToWireScenario extends FlowScenario { ), target = ctx.outputs.assert(SwapToWireScenario.Output.target), fee = ctx.outputs.assert(SwapToWireScenario.Output.wireLegFee), + // The payout leg only leaves custody once the recipient claims it, which the + // recipient-paid-exact step above already did; the fee's emissions half left at + // settlement and its rewards half drains at the epoch boundary. expectedCustody = custodyBefore - target - fee await pollUntil( "rewards bucket drained from sysio.reserv custody", From 5742a26efb8ec3914ec6d562c3e691c46f11d581 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 13 Aug 2026 12:42:54 -0500 Subject: [PATCH 2/6] fix(swap-to-wire): hoist the claimable poll into a named predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit on #62: the poll predicate was an inline async lambda that awaited inside itself. `claimableReached` now sits with the other reads beside `underwritersActive`, and the call site passes `() => claimableReached(...)` — the shape every other read-backed poll in this scenario already uses. No behaviour change. --- .../src/SwapToWireScenario.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts index 8803c230..d891ff13 100644 --- a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts +++ b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts @@ -76,6 +76,27 @@ async function underwritersActive( }) } +/** + * Whether `account`'s `sysio.reserv::wireclaims` credit has reached `target` + * (a read). + * + * `paywire` credits a claimable balance rather than transferring, so the + * recipient's token balance stays flat until it pulls with `claimwire` — this + * reads the claim ledger, not the balance. + * + * @param ctx - The scenario context. + * @param account - The payout recipient's WIRE account. + * @param target - The post-fee WIRE amount the credit must reach. + * @returns Whether the claimable credit is at or above `target`. + */ +async function claimableReached( + ctx: SwapScenarioContext, + account: string, + target: bigint +): Promise { + return (await ctx.wire.getWireClaimable(account)) >= target +} + /** * The to-WIRE uwreq row (src=ETHEREUM, dst=WIRE) — throws when the depot has * not created it (a read). @@ -577,8 +598,7 @@ export class SwapToWireScenario extends FlowScenario { // not the token balance — the latter stays at zero until the recipient pulls it. await pollUntil( "recipient claimable WIRE reaches the target", - async () => - (await ctx.wire.getWireClaimable(recipient.account)) >= target, + () => claimableReached(ctx, recipient.account, target), Constants.PayoutDeadlineMs, Constants.LongPollIntervalMs ) From 2ca97332764af6ff072ebeae622b457491ce0773 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 13 Aug 2026 13:01:00 -0500 Subject: [PATCH 3/6] fix(swap-to-wire): return the claimable poll predicate from a factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claimableReached` was an async predicate the call site wrapped in an inline lambda. `claimableReachedPredicate` binds `ctx`, `account`, and `target` and hands `pollUntil` the predicate directly — `() => Promise`, which is `pollUntil`'s own predicate parameter type. --- .../flow-swap-to-wire/src/SwapToWireScenario.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts index d891ff13..c691fd06 100644 --- a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts +++ b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts @@ -77,8 +77,8 @@ async function underwritersActive( } /** - * Whether `account`'s `sysio.reserv::wireclaims` credit has reached `target` - * (a read). + * Predicate factory — whether `account`'s `sysio.reserv::wireclaims` credit has + * reached `target` (a read). * * `paywire` credits a claimable balance rather than transferring, so the * recipient's token balance stays flat until it pulls with `claimwire` — this @@ -87,14 +87,15 @@ async function underwritersActive( * @param ctx - The scenario context. * @param account - The payout recipient's WIRE account. * @param target - The post-fee WIRE amount the credit must reach. - * @returns Whether the claimable credit is at or above `target`. + * @returns A poll predicate for whether the credit is at or above `target`. */ -async function claimableReached( +function claimableReachedPredicate( ctx: SwapScenarioContext, account: string, target: bigint -): Promise { - return (await ctx.wire.getWireClaimable(account)) >= target +): () => Promise { + return () => + ctx.wire.getWireClaimable(account).then(amount => amount >= target) } /** @@ -598,7 +599,7 @@ export class SwapToWireScenario extends FlowScenario { // not the token balance — the latter stays at zero until the recipient pulls it. await pollUntil( "recipient claimable WIRE reaches the target", - () => claimableReached(ctx, recipient.account, target), + claimableReachedPredicate(ctx, recipient.account, target), Constants.PayoutDeadlineMs, Constants.LongPollIntervalMs ) From bb39e073f60d82084baa2123ab7291e64211c94d Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 14 Aug 2026 13:06:04 -0500 Subject: [PATCH 4/6] fix(cluster-tool): name the claimable row shape instead of inlining it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eslint` bans inline object types (STYLE.md "Interface Design"), and the two claimable-balance readers each passed one as the `getTableRows` generic. Both now use `WireClient.ClaimableRow`, declared in the companion namespace where the client's other sub-types live. Nothing had caught this: wire-tools-ts runs no PR checks, and lint only executes inside the e2e gate's `//wire-tools-ts:build` — where it would have failed the run before a single flow started. `claimPay`'s doc also stops advertising a category-bucket share. `payepoch` transfers to `sysio.ops` / `sysio.gov` directly, because ROA zeroes net/cpu for every sysio-prefixed account and neither carries a contract that could emit the claim inline, so neither could ever authorize one. build + lint green; jest unchanged against master's baseline on this host. Change-Id: I3e1a147f9cef30553b5b3d8537d3bb10d780a852 --- .../src/clients/wire/WireClient.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/cluster-tool/src/clients/wire/WireClient.ts b/packages/cluster-tool/src/clients/wire/WireClient.ts index 8f84d081..9b8da80f 100644 --- a/packages/cluster-tool/src/clients/wire/WireClient.ts +++ b/packages/cluster-tool/src/clients/wire/WireClient.ts @@ -442,7 +442,7 @@ export class WireClient { * version is released and this package's dependency is bumped. */ async getWireClaimable(account: string): Promise { - const { rows } = await this.getTableRows<{ balance: string | number }>({ + const { rows } = await this.getTableRows({ account: "sysio.reserv", scope: "sysio.reserv", table: "wireclaims", @@ -454,9 +454,13 @@ export class WireClient { } /** - * Pull `account`'s credited epoch pay from `sysio.system` — producer, standby, batch-operator and - * category-bucket shares. `payepoch` credits rather than transfers for the same reason as above: + * Pull `account`'s credited epoch pay from `sysio.system` — a producer, standby or + * batch-operator share. `payepoch` credits rather than transfers for the same reason as above: * it runs inline from `sysio.epoch::advance`, which must never abort. + * + * The T5 category buckets (`sysio.ops`, `sysio.gov`) are NOT claimable: `payepoch` transfers to + * them directly, because ROA zeroes net/cpu for every `sysio`-prefixed account and neither + * carries a contract that could emit the claim inline, so neither could ever authorize one. */ async claimPay(account: string, permission = "active") { return this.invoke("sysio", "claimpay", { account_name: account }, [ @@ -467,7 +471,7 @@ export class WireClient { /** Epoch pay owed to `account` but not yet claimed, or 0n when there is no row. Raw table read * for the same reason as {@link getWireClaimable}. */ async getPayClaimable(account: string): Promise { - const { rows } = await this.getTableRows<{ balance: string | number }>({ + const { rows } = await this.getTableRows({ account: "sysio", scope: "sysio", table: "payclaims", @@ -917,6 +921,20 @@ function errorText(err: unknown): string { } export namespace WireClient { + /** + * The single field a claimable-balance read consumes, shared by `sysio.reserv::wireclaims` and + * `sysio.system::payclaims` — both rows carry `balance` in atomic units, serialized as a string + * once it exceeds the JSON-safe integer range. + * + * Declared here rather than taken from `SysioContracts` because these two reads are deliberately + * raw (see {@link WireClient.getWireClaimable}): the generated row types do not reach an + * `@wireio/sdk-core` release until the contract ABIs land, so typing the generic against them + * would couple this package's build to that release. It retires with the raw reads. + */ + export interface ClaimableRow { + balance: string | number + } + // ── Contract-client typing (keyed by contract Name + member) ── export interface InvocationOptions { authorization?: PermissionLevelType[] From c50b63089fe9cb65954a40982a0fb141591cae28 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 14 Aug 2026 19:03:07 -0500 Subject: [PATCH 5/6] fix(cluster-tool): encode the claimable-table bounds the node can actually parse `getWireClaimable` and `getPayClaimable` passed the bare account name as lower_bound/upper_bound. With json=true the node runs each bound through `fc::json::from_string` and then `be_key_codec::encode_key`, so `wirercpt` is not even valid JSON -- nodeop answered parse_error_exception: Unexpected char '119' in "wirercpt" at /v1/chain/get_table_rows and flow-swap-to-wire's recipient-paid-exact step died on the first poll. That path had never run in CI before: every earlier run of this stack wedged at bootstrap, so this is the first time the claim read was reached. Three things were wrong, per chain_plugin.cpp:2349-2360 and database_utils.hpp: the bound must be JSON; `encode_key` calls `.get_object()` and looks each field up BY NAME from the ABI's key_names; and the leaf is uint64, encoded through `val.as_uint64()`. `WireClient.nameKeyBound(field, account)` builds exactly that. The field name is a parameter because the two tables disagree -- `wireclaims.account` vs `payclaims.account_name` -- and the uint64 rides as a decimal string: a name's raw value (wirercpt = 16406239207934132224) is far past Number.MAX_SAFE_INTEGER, and `as_uint64` parses a string variant. Unit tests cover the object shape, the per-table field name, the decimal-string type, and that distinct accounts produce distinct bounds. 23/23 green; lint clean. Change-Id: If9d5f30c44355b5b220e16d2279efc53214f004a --- .../src/clients/wire/WireClient.ts | 27 +++++++++++--- .../tests/clients/wire/WireClient.test.ts | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/cluster-tool/src/clients/wire/WireClient.ts b/packages/cluster-tool/src/clients/wire/WireClient.ts index 9b8da80f..bd4c4d34 100644 --- a/packages/cluster-tool/src/clients/wire/WireClient.ts +++ b/packages/cluster-tool/src/clients/wire/WireClient.ts @@ -15,6 +15,7 @@ import { API, APIClient, Asset, + Name, type PermissionLevelType, SysioContracts } from "@wireio/sdk-core" @@ -442,12 +443,13 @@ export class WireClient { * version is released and this package's dependency is bumped. */ async getWireClaimable(account: string): Promise { + const bound = WireClient.nameKeyBound("account", account) const { rows } = await this.getTableRows({ account: "sysio.reserv", scope: "sysio.reserv", table: "wireclaims", - lowerBound: account, - upperBound: account, + lowerBound: bound, + upperBound: bound, limit: 1 }) return rows.length === 0 ? 0n : BigInt(rows[0].balance) @@ -471,12 +473,13 @@ export class WireClient { /** Epoch pay owed to `account` but not yet claimed, or 0n when there is no row. Raw table read * for the same reason as {@link getWireClaimable}. */ async getPayClaimable(account: string): Promise { + const bound = WireClient.nameKeyBound("account_name", account) const { rows } = await this.getTableRows({ account: "sysio", scope: "sysio", table: "payclaims", - lowerBound: account, - upperBound: account, + lowerBound: bound, + upperBound: bound, limit: 1 }) return rows.length === 0 ? 0n : BigInt(rows[0].balance) @@ -921,6 +924,22 @@ function errorText(err: unknown): string { } export namespace WireClient { + /** + * A `get_table_rows` bound for a KV table whose key is ONE `uint64` field holding a `name`. + * + * With `json: true` the node parses each bound through `fc::json::from_string` and encodes it + * with `be_key_codec::encode_key`, which calls `.get_object()` and looks every key field up BY + * NAME (`chain_plugin.cpp` + `database_utils.hpp`). So the bound must be a JSON OBJECT keyed by + * the ABI's `key_names`, carrying the name's raw uint64 — never the account string, which the + * node cannot even parse as JSON ("Unexpected char '119' in \"wirercpt\""). + * + * The field name differs per table (`wireclaims.account` vs `payclaims.account_name`), so the + * caller supplies it; the uint64 goes as a decimal STRING because it exceeds `Number.MAX_SAFE_INTEGER`. + */ + export function nameKeyBound(field: string, account: string): string { + return JSON.stringify({ [field]: Name.from(account).value.toString() }) + } + /** * The single field a claimable-balance read consumes, shared by `sysio.reserv::wireclaims` and * `sysio.system::payclaims` — both rows carry `balance` in atomic units, serialized as a string diff --git a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts index fd9106cc..8ae6d076 100644 --- a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts +++ b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts @@ -177,6 +177,42 @@ describe("WireClient", () => { }) }) + describe("nameKeyBound", () => { + // The node parses a json=true bound with fc::json::from_string and encodes it via + // be_key_codec::encode_key, which does .get_object() and looks each key field up BY NAME. + // Passing the bare account string made nodeop fail at parse time with + // `parse_error_exception: Unexpected char '119' in "wirercpt"` — 'w', the first character of + // the name — which is what took flow-swap-to-wire's recipient-paid-exact step down. + it("emits a JSON object keyed by the ABI key field, not the bare account", () => { + const bound = WireClient.nameKeyBound("account", "wirercpt") + expect(bound).not.toBe("wirercpt") + const parsed = JSON.parse(bound) + expect(Object.keys(parsed)).toEqual(["account"]) + }) + + it("carries the name's raw uint64 as a decimal string", () => { + // key_types is ["uint64"], and a name's raw value exceeds Number.MAX_SAFE_INTEGER, so it + // must not ride as a JSON number. + const { account } = JSON.parse(WireClient.nameKeyBound("account", "wirercpt")) + expect(typeof account).toBe("string") + expect(account).toMatch(/^[0-9]+$/) + expect(BigInt(account)).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER)) + }) + + it("honours the per-table key field name", () => { + // wireclaims keys on `account`; payclaims keys on `account_name`. One helper, two shapes. + expect(Object.keys(JSON.parse(WireClient.nameKeyBound("account_name", "wirercpt")))).toEqual([ + "account_name" + ]) + }) + + it("round-trips distinct accounts to distinct bounds", () => { + expect(WireClient.nameKeyBound("account", "wirercpt")).not.toBe( + WireClient.nameKeyBound("account", "wireno.aaa") + ) + }) + }) + describe("transaction expiration", () => { it("pins an expiration well above clio's 30s default", () => { // clio's default is the SIGN->INCLUSION window, not execution. On a large From 86f1f0bce0be773d7345cacef9a39bd59a8b960c Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 14 Aug 2026 21:54:32 -0500 Subject: [PATCH 6/6] fix(cluster-tool): read the claimable row with a lower bound only The encoded bound from c50b6308 made the query parseable, but it still could not return anything: the node's upper bound is EXCLUSIVE -- chain_plugin.cpp: if (has_upper && kv >= ub_sv) break; (the exclusive increment above it applies to `find`, not to lower/upper) -- so lower == upper describes an empty range. flow-swap-to-wire stopped erroring at `recipient-paid-exact` and started timing out there instead, 120s of polling a query that could never match. Reads now pass a lower bound only. Keys encode big-endian, so iteration is numeric order and the first row at-or-after the key is this account's IF it has one -- when it does not, the walk yields the NEXT account's row. The identity check on the returned row is therefore load-bearing, not defensive: without it an account with no claim reads back a stranger's balance. Both readers now share one `claimableBalance` helper rather than duplicating the block, since this is the second defect to be fixed in two copies of it. Verified end to end: flow-swap-to-wire SUCCEEDED locally against nodeop v1.0.0-a0aceec3c3 (wire-sysio#558 merged with master), zero failed steps. Unit tests cover the absent upper bound, the matching row, a foreign row reading back 0n, an empty table, and payclaims' distinct key + row field names. 28/28 green; lint clean. Change-Id: I43005967d4f8ff171c89cc94d01836f031ca6da7 --- .../src/clients/wire/WireClient.ts | 53 ++++++++++----- .../tests/clients/wire/WireClient.test.ts | 68 +++++++++++++++++++ 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/packages/cluster-tool/src/clients/wire/WireClient.ts b/packages/cluster-tool/src/clients/wire/WireClient.ts index bd4c4d34..0d3cc86b 100644 --- a/packages/cluster-tool/src/clients/wire/WireClient.ts +++ b/packages/cluster-tool/src/clients/wire/WireClient.ts @@ -443,16 +443,40 @@ export class WireClient { * version is released and this package's dependency is bumped. */ async getWireClaimable(account: string): Promise { - const bound = WireClient.nameKeyBound("account", account) + return this.claimableBalance("sysio.reserv", "wireclaims", "account", account) + } + + /** + * One claimable row's balance, or 0n when the account has none. + * + * Reads with a LOWER bound only. The node's upper bound is EXCLUSIVE + * (`chain_plugin.cpp`: `if (has_upper && kv >= ub_sv) break;` — the exclusive increment at the + * `find` branch does not apply here), so passing lower == upper describes an empty range and + * returns nothing however long you poll. + * + * Keys encode big-endian (`be_key_codec`), so iteration is numeric order and the first row + * at-or-after the key belongs to this account IF it has one. When it does not, the walk yields + * the NEXT account's row — which is why the identity check is load-bearing here, not defensive: + * without it an unpaid account reads back a stranger's balance. + */ + private async claimableBalance( + contract: string, + table: string, + keyField: string, + account: string + ): Promise { const { rows } = await this.getTableRows({ - account: "sysio.reserv", - scope: "sysio.reserv", - table: "wireclaims", - lowerBound: bound, - upperBound: bound, + account: contract, + scope: contract, + table, + lowerBound: WireClient.nameKeyBound(keyField, account), limit: 1 }) - return rows.length === 0 ? 0n : BigInt(rows[0].balance) + const [row] = rows + if (row == null) return 0n + // wireclaims names the row's account `account`; payclaims names it `account_name`. + const { account: rowAccount, account_name: rowAccountName, balance } = row + return (rowAccount ?? rowAccountName) === account ? BigInt(balance) : 0n } /** @@ -473,16 +497,7 @@ export class WireClient { /** Epoch pay owed to `account` but not yet claimed, or 0n when there is no row. Raw table read * for the same reason as {@link getWireClaimable}. */ async getPayClaimable(account: string): Promise { - const bound = WireClient.nameKeyBound("account_name", account) - const { rows } = await this.getTableRows({ - account: "sysio", - scope: "sysio", - table: "payclaims", - lowerBound: bound, - upperBound: bound, - limit: 1 - }) - return rows.length === 0 ? 0n : BigInt(rows[0].balance) + return this.claimableBalance("sysio", "payclaims", "account_name", account) } // Convenience getters delegate to the typed contract-table accessor @@ -952,6 +967,10 @@ export namespace WireClient { */ export interface ClaimableRow { balance: string | number + /** `wireclaims` carries the row's owner here… */ + account?: string + /** …and `payclaims` here. Exactly one is present, per that table's ABI. */ + account_name?: string } // ── Contract-client typing (keyed by contract Name + member) ── diff --git a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts index 8ae6d076..4f476783 100644 --- a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts +++ b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts @@ -177,6 +177,74 @@ describe("WireClient", () => { }) }) + describe("claimable reads", () => { + // Two defects lived in these two lines. First the bound was the bare account name, which the + // node cannot parse as JSON. Then, with that fixed, lower == upper described an EMPTY range: + // chain_plugin breaks on `kv >= ub_sv`, so the row can never come back and the flow's poll + // times out instead of erroring. Both were only reachable from flow-swap-to-wire. + const rowsFor = (client: WireClient, captured: any[]) => + jest + .spyOn(client, "getTableRows") + .mockImplementation(async (query: any) => { + captured.push(query) + return { rows: [{ account: "wirercpt", balance: "1234" }], more: false } as never + }) + + it("sends a lower bound and NO upper bound", async () => { + const client = new WireClient(config), + captured: any[] = [] + rowsFor(client, captured) + await client.getWireClaimable("wirercpt") + const [query] = captured + expect(query.lowerBound).toBe(WireClient.nameKeyBound("account", "wirercpt")) + expect(query.upperBound).toBeUndefined() + }) + + it("returns the balance when the row belongs to the account", async () => { + const client = new WireClient(config) + rowsFor(client, []) + expect(await client.getWireClaimable("wirercpt")).toBe(1234n) + }) + + it("returns 0n when the walk lands on the NEXT account's row", async () => { + // lower_bound returns the first row at-or-after the key, so an account with no row reads + // back a stranger's. Without the identity check this reported someone else's balance. + const client = new WireClient(config) + jest + .spyOn(client, "getTableRows") + .mockResolvedValue({ + rows: [{ account: "wireother", balance: "999" }], + more: false + } as never) + expect(await client.getWireClaimable("wirercpt")).toBe(0n) + }) + + it("returns 0n when the table has no rows at all", async () => { + const client = new WireClient(config) + jest + .spyOn(client, "getTableRows") + .mockResolvedValue({ rows: [], more: false } as never) + expect(await client.getWireClaimable("wirercpt")).toBe(0n) + }) + + it("reads payclaims through its own key + row field names", async () => { + const client = new WireClient(config), + captured: any[] = [] + jest.spyOn(client, "getTableRows").mockImplementation(async (query: any) => { + captured.push(query) + return { + rows: [{ account_name: "wirercpt", balance: "77" }], + more: false + } as never + }) + expect(await client.getPayClaimable("wirercpt")).toBe(77n) + expect(captured[0].table).toBe("payclaims") + expect(captured[0].lowerBound).toBe( + WireClient.nameKeyBound("account_name", "wirercpt") + ) + }) + }) + describe("nameKeyBound", () => { // The node parses a json=true bound with fc::json::from_string and encodes it via // be_key_codec::encode_key, which does .get_object() and looks each key field up BY NAME.