Skip to content
Merged
62 changes: 62 additions & 0 deletions packages/cluster-tool/src/clients/wire/WireClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint> {
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<bigint> {
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() {
Expand Down
12 changes: 10 additions & 2 deletions packages/flow-swap-to-wire/src/SwapToWireScenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,13 +571,19 @@ export class SwapToWireScenario extends FlowScenario<SwapScenarioContext> {
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
Expand Down Expand Up @@ -634,6 +640,8 @@ export class SwapToWireScenario extends FlowScenario<SwapScenarioContext> {
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
),
Expand Down