Skip to content
Open
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
35 changes: 32 additions & 3 deletions packages/flow-swap-to-wire/src/SwapToWireScenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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).
Expand Down Expand Up @@ -571,13 +593,18 @@ 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",
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
Expand Down Expand Up @@ -634,6 +661,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