From 4da72b183e6fd460947c5191feba19278b8f8115 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 13 Jul 2026 18:11:22 +0100 Subject: [PATCH] fix(frontend): claim-all post-condition must bound by remaining, not claimable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claim-all pays out the claimable balance at execution time, but the deny-mode post-condition capped the contract's outflow at the claimable amount sampled when the button was clicked. Claimable grows every ~5s block, so by the time the tx mined the payout exceeded the stale bound and the chain aborted with abort_by_post_condition — on any active stream, every time. It never surfaced before because earlier claims ran against finished streams, where accrual had stopped. Bound by remaining escrow (deposit − withdrawn) instead: it only shrinks, so it always covers the payout, while still capping outflow at what the stream actually holds — the protection M-3 intended. The cancel flow already used this bound and matches the contract's total outflow exactly; partial claim sends an explicit amount and was never affected. --- frontend/src/app/earn/page.tsx | 3 ++- frontend/src/app/earn/streams/page.tsx | 3 ++- frontend/src/lib/stacks.ts | 13 ++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/earn/page.tsx b/frontend/src/app/earn/page.tsx index 6edbb67..7775f25 100644 --- a/frontend/src/app/earn/page.tsx +++ b/frontend/src/app/earn/page.tsx @@ -112,7 +112,8 @@ export default function EarnPage() { streamId: stream.id, tokenContract: stream.token, ftName: getTokenConfigByContractId(stream.token).ftName, - expectedAmount: stream.claimable ?? 0n, + remainingBalance: + stream.depositAmount - stream.withdrawnAmount, }) ); if (result?.confirmed) { diff --git a/frontend/src/app/earn/streams/page.tsx b/frontend/src/app/earn/streams/page.tsx index 2411139..4008a18 100644 --- a/frontend/src/app/earn/streams/page.tsx +++ b/frontend/src/app/earn/streams/page.tsx @@ -71,7 +71,8 @@ export default function EarnStreamsPage() { streamId: stream.id, tokenContract: stream.token, ftName: getTokenConfigByContractId(stream.token).ftName, - expectedAmount: stream.claimable ?? 0n, + remainingBalance: + stream.depositAmount - stream.withdrawnAmount, }) ); if (result?.confirmed) { diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts index f8ebe65..47a0258 100644 --- a/frontend/src/lib/stacks.ts +++ b/frontend/src/lib/stacks.ts @@ -295,8 +295,15 @@ export function buildClaimAllTx(params: { streamId: number; tokenContract: string; ftName: string; - /** Expected claimable amount fetched pre-build (upper bound). */ - expectedAmount: bigint; + /** + * Stable upper bound for the payout: deposit − withdrawn (the stream's + * remaining escrow). NEVER bound this by the claimable amount — claimable + * grows every block, so a bound sampled at build time is already stale + * when the tx mines and claim-all aborts by post-condition on any active + * stream. Remaining only shrinks (on claims), so it always covers the + * payout while still capping outflow at what the stream actually holds. + */ + remainingBalance: bigint; }) { const [mgrAddr, mgrName] = splitContract(STREAM_MANAGER_CONTRACT); @@ -311,7 +318,7 @@ export function buildClaimAllTx(params: { postConditionMode: PostConditionMode.Deny, postConditions: [ Pc.principal(`${mgrAddr}.${mgrName}`) - .willSendLte(params.expectedAmount) + .willSendLte(params.remainingBalance) .ft(params.tokenContract as `${string}.${string}`, params.ftName), ], network: getNetwork(),