Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d095a64
fix: surface partial batch tip failures after tx confirmation
Mosas2000 Mar 15, 2026
ad9e176
chore(issue-238): progress checkpoint 1
Mosas2000 Mar 15, 2026
da8dd45
chore(issue-238): progress checkpoint 2
Mosas2000 Mar 15, 2026
d30f2e2
chore(issue-238): progress checkpoint 3
Mosas2000 Mar 15, 2026
c2dfb3c
chore(issue-238): progress checkpoint 4
Mosas2000 Mar 15, 2026
5b2671d
chore(issue-238): progress checkpoint 5
Mosas2000 Mar 15, 2026
cbd38af
chore(issue-238): progress checkpoint 6
Mosas2000 Mar 15, 2026
273adea
chore(issue-238): progress checkpoint 7
Mosas2000 Mar 15, 2026
d527007
chore(issue-238): progress checkpoint 8
Mosas2000 Mar 15, 2026
6eec5b1
chore(issue-238): progress checkpoint 9
Mosas2000 Mar 15, 2026
908bd9b
chore(issue-238): progress checkpoint 10
Mosas2000 Mar 15, 2026
a698c1b
chore(issue-238): progress checkpoint 11
Mosas2000 Mar 15, 2026
cb27cea
chore(issue-238): progress checkpoint 12
Mosas2000 Mar 15, 2026
41cf891
chore(issue-238): progress checkpoint 13
Mosas2000 Mar 15, 2026
c71bb73
chore(issue-238): progress checkpoint 14
Mosas2000 Mar 15, 2026
b4ec395
chore(issue-238): progress checkpoint 15
Mosas2000 Mar 15, 2026
970a3b0
chore(issue-238): progress checkpoint 16
Mosas2000 Mar 15, 2026
3108c18
chore(issue-238): progress checkpoint 17
Mosas2000 Mar 15, 2026
b24d033
chore(issue-238): progress checkpoint 18
Mosas2000 Mar 15, 2026
61d9a54
chore(issue-238): progress checkpoint 19
Mosas2000 Mar 15, 2026
5ae50a1
chore(issue-238): progress checkpoint 20
Mosas2000 Mar 15, 2026
6eb834d
chore(issue-238): progress checkpoint 21
Mosas2000 Mar 15, 2026
3eefb20
chore(issue-238): progress checkpoint 22
Mosas2000 Mar 15, 2026
8912838
chore(issue-238): progress checkpoint 23
Mosas2000 Mar 15, 2026
739f4e1
chore(issue-238): progress checkpoint 24
Mosas2000 Mar 15, 2026
1dad9c6
chore(issue-238): progress checkpoint 25
Mosas2000 Mar 15, 2026
b87e790
chore(issue-238): progress checkpoint 26
Mosas2000 Mar 15, 2026
bb1da3a
chore(issue-238): progress checkpoint 27
Mosas2000 Mar 15, 2026
f3a1c77
chore(issue-238): progress checkpoint 28
Mosas2000 Mar 15, 2026
85f3448
chore(issue-238): progress checkpoint 29
Mosas2000 Mar 15, 2026
2d59ff7
chore(issue-238): progress checkpoint 30
Mosas2000 Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- `BatchTip` now reports accurate outcome summaries after on-chain
confirmation instead of always showing a blanket success toast. Non-strict
batch results are parsed to show full success, partial success, or all
failed outcomes (Issue #238).

- `RecentTips` tip-back modal now provides complete dialog keyboard support:
it traps `Tab`/`Shift+Tab` focus within the modal, closes on `Escape`,
restores focus to the previously focused trigger on close, and supports
Expand Down Expand Up @@ -56,6 +61,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
`Escape` close with focus restoration, focus trapping, and backdrop
click close behavior.

### Added (Issue #238)

- `frontend/src/lib/batchTipResults.js` with result parsing helpers used to
summarize per-recipient outcomes from confirmed batch-tip transactions.
- `frontend/src/test/batch-tip-results.test.js` with 6 tests covering
non-strict result parsing, strict-mode fallback parsing, and final
user-facing outcome message generation.

- Four components (`Leaderboard`, `RecentTips`, `TipHistory`,
`useNotifications`) each polled the same Stacks API contract-events
endpoint on independent intervals, generating up to 15+ requests per
Expand Down
37 changes: 33 additions & 4 deletions frontend/src/components/BatchTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
} from '@stacks/transactions';
import { network, appDetails, getSenderAddress } from '../utils/stacks';
import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_SEND_BATCH_TIPS, FN_SEND_BATCH_TIPS_STRICT } from '../config/contracts';
import { toMicroSTX, formatSTX, formatAddress } from '../lib/utils';

Check failure on line 14 in frontend/src/components/BatchTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'formatAddress' is defined but never used. Allowed unused vars must match /^[A-Z_]/u

Check failure on line 14 in frontend/src/components/BatchTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'formatSTX' is defined but never used. Allowed unused vars must match /^[A-Z_]/u
import { formatBalance } from '../lib/balance-utils';
import { analytics } from '../lib/analytics';
import { summarizeBatchTipResult, buildBatchTipOutcomeMessage } from '../lib/batchTipResults';
import { useBalance } from '../hooks/useBalance';
import { useTipContext } from '../context/TipContext';
import { Users, Plus, Trash2, Send, Loader2, AlertTriangle } from 'lucide-react';
import ConfirmDialog from './ui/confirm-dialog';
import TxStatus from './ui/tx-status';

const MAX_BATCH_SIZE = 50;
const MIN_TIP_STX = 0.001;
Expand All @@ -33,6 +35,7 @@
const [sending, setSending] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errors, setErrors] = useState({});
const [pendingTx, setPendingTx] = useState(null);

const senderAddress = useMemo(() => getSenderAddress(), []);

Expand Down Expand Up @@ -187,13 +190,12 @@
postConditionMode: PostConditionMode.Deny,
onFinish: (data) => {
setSending(false);
analytics.trackBatchTipConfirmed();
setPendingTx({ txId: data.txId, totalRecipients: recipients.length, strictMode });
setRecipients([emptyRecipient()]);
setErrors({});
notifyTipSent();
addToast?.(
`Batch of ${recipients.length} tips sent! Tx: ${data.txId}`,
'success'
`Batch transaction submitted. Waiting for confirmation... Tx: ${data.txId}`,
'info'
);
},
onCancel: () => {
Expand All @@ -210,6 +212,25 @@
}
};

const handleBatchTxConfirmed = useCallback((txData) => {
analytics.trackBatchTipConfirmed();
notifyTipSent();

const summary = summarizeBatchTipResult(txData, pendingTx?.totalRecipients ?? 0);
const toastType = summary.failureCount === 0
? 'success'
: summary.successCount > 0
? 'warning'
: 'error';

addToast?.(buildBatchTipOutcomeMessage(summary), toastType);
}, [addToast, notifyTipSent, pendingTx?.totalRecipients]);

const handleBatchTxFailed = useCallback((reason) => {
analytics.trackBatchTipFailed();
addToast?.(`Batch transaction failed: ${reason}`, 'error');
}, [addToast]);

return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6">
Expand Down Expand Up @@ -407,6 +428,14 @@
</>
)}
</button>

{pendingTx?.txId && (
<TxStatus
txId={pendingTx.txId}
onConfirmed={handleBatchTxConfirmed}
onFailed={handleBatchTxFailed}
/>
)}
</div>

<ConfirmDialog
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/lib/batchTipResults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Parse a confirmed batch tip tx result and count per-tip outcomes.
*
* For non-strict mode, tx_result.repr is expected to look like:
* (ok (list (ok u1) (err u108) ...))
*/
export function summarizeBatchTipResult(txData, expectedTotal = 0) {
const repr = txData?.tx_result?.repr;

if (typeof repr !== 'string' || repr.length === 0) {
return {
total: expectedTotal,
successCount: expectedTotal,
failureCount: 0,
parsed: false,
};
}

const isListResult = /^\(ok \(list/.test(repr);
const okItemMatches = repr.match(/\(ok u\d+\)/g) || [];
const errItemMatches = repr.match(/\(err u\d+\)/g) || [];

if (isListResult) {
const total = okItemMatches.length + errItemMatches.length;
return {
total,
successCount: okItemMatches.length,
failureCount: errItemMatches.length,
parsed: true,
};
}

const strictMatch = repr.match(/^\(ok u(\d+)\)$/);
if (strictMatch) {
const successCount = Number(strictMatch[1]);
const total = expectedTotal > 0 ? expectedTotal : successCount;
return {
total,
successCount,
failureCount: Math.max(0, total - successCount),
parsed: true,
};
}

return {
total: expectedTotal,
successCount: expectedTotal,
failureCount: 0,
parsed: false,
};
}

export function buildBatchTipOutcomeMessage(summary) {
const total = summary?.total ?? 0;
const successCount = summary?.successCount ?? 0;
const failureCount = summary?.failureCount ?? 0;

if (total <= 0) {
return 'Batch transaction confirmed. No tip outcomes were reported.';
}

if (failureCount <= 0) {
return `${successCount} of ${total} tips sent successfully`;
}

if (successCount <= 0) {
return 'Batch transaction completed but all tips failed';
}

return `${successCount} of ${total} tips sent. ${failureCount} failed (see transaction details)`;
}
60 changes: 60 additions & 0 deletions frontend/src/test/batch-tip-results.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { summarizeBatchTipResult, buildBatchTipOutcomeMessage } from '../lib/batchTipResults';

describe('summarizeBatchTipResult', () => {
it('counts per-tip ok/err outcomes from a non-strict list result', () => {
const txData = {
tx_result: {
repr: '(ok (list (ok u1) (err u108) (ok u2) (err u101)))',
},
};

expect(summarizeBatchTipResult(txData, 4)).toEqual({
total: 4,
successCount: 2,
failureCount: 2,
parsed: true,
});
});

it('parses strict-mode count result', () => {
const txData = {
tx_result: {
repr: '(ok u3)',
},
};

expect(summarizeBatchTipResult(txData, 5)).toEqual({
total: 5,
successCount: 3,
failureCount: 2,
parsed: true,
});
});

it('falls back to expected total when repr is missing', () => {
expect(summarizeBatchTipResult({}, 3)).toEqual({
total: 3,
successCount: 3,
failureCount: 0,
parsed: false,
});
});
});

describe('buildBatchTipOutcomeMessage', () => {
it('returns full success message', () => {
const msg = buildBatchTipOutcomeMessage({ total: 5, successCount: 5, failureCount: 0 });
expect(msg).toBe('5 of 5 tips sent successfully');
});

it('returns partial success message', () => {
const msg = buildBatchTipOutcomeMessage({ total: 5, successCount: 3, failureCount: 2 });
expect(msg).toBe('3 of 5 tips sent. 2 failed (see transaction details)');
});

it('returns all failed message', () => {
const msg = buildBatchTipOutcomeMessage({ total: 5, successCount: 0, failureCount: 5 });
expect(msg).toBe('Batch transaction completed but all tips failed');
});
});
Loading