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
34 changes: 31 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,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<boolean> {
return (await ctx.wire.getWireClaimable(account)) >= target
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't have try to fix my nit picking lol

function claimableReachedPredicate(
  ctx: SwapScenarioContext,
  account: string,
  target: bigint
): AsyncFunction<boolean> {
  return () => ctx.wire.getWireClaimable(account).then(amount => amount >= target)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol, ok

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2ca9733claimableReachedPredicate(ctx, account, target) returns the bound predicate and pollUntil takes it directly, no inline lambda.

One substitution: AsyncFunction isn't exported anywhere in the tree (not @wireio/shared, not @wireio/cluster-tool, not the deps), so the return type is spelled () => Promise<boolean>pollUntil's own predicate parameter type. getWireClaimable returns Promise<bigint> and target is a bigint, so the .then narrows as expected.

underwritersActive is left as-is: it predates this PR on master, so converting it would widen the diff past the review.


/**
* 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 +592,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",
() => claimableReached(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 +660,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