diff --git a/packages/cluster-tool/src/clients/wire/WireClient.ts b/packages/cluster-tool/src/clients/wire/WireClient.ts index 45a78c33..0d3cc86b 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" @@ -416,6 +417,89 @@ 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 { + 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: contract, + scope: contract, + table, + lowerBound: WireClient.nameKeyBound(keyField, account), + limit: 1 + }) + 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 + } + + /** + * 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 }, [ + { 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 { + return this.claimableBalance("sysio", "payclaims", "account_name", account) + } + // Convenience getters delegate to the typed contract-table accessor // (prefer-typed-contract-table-accessors.md) — never a raw getTableRows. getOperators() { @@ -855,6 +939,40 @@ 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 + * 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 + /** `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) ── export interface InvocationOptions { authorization?: PermissionLevelType[] diff --git a/packages/cluster-tool/tests/clients/wire/WireClient.test.ts b/packages/cluster-tool/tests/clients/wire/WireClient.test.ts index fd9106cc..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,110 @@ 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. + // 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 diff --git a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts index 83cc752c..c691fd06 100644 --- a/packages/flow-swap-to-wire/src/SwapToWireScenario.ts +++ b/packages/flow-swap-to-wire/src/SwapToWireScenario.ts @@ -76,6 +76,28 @@ async function underwritersActive( }) } +/** + * 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 + * 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 A poll predicate for whether the credit is at or above `target`. + */ +function claimableReachedPredicate( + ctx: SwapScenarioContext, + account: string, + target: bigint +): () => Promise { + return () => + ctx.wire.getWireClaimable(account).then(amount => amount >= target) +} + /** * The to-WIRE uwreq row (src=ETHEREUM, dst=WIRE) — throws when the depot has * not created it (a read). @@ -571,13 +593,18 @@ 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", - async () => - (await ctx.wire.getWireBalance(recipient.account)) >= target, + "recipient claimable WIRE reaches the target", + claimableReachedPredicate(ctx, 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 @@ -634,6 +661,8 @@ export class SwapToWireScenario extends FlowScenario { SwapToWireScenario.Output.custodyBefore ), target = ctx.outputs.assert(SwapToWireScenario.Output.target), + // The payout leg only leaves custody once the recipient claims it, + // which the recipient-paid-exact step above already did. { rewardShare } = ctx.outputs.assert( SwapToWireScenario.Output.wireLegFee ),