Activity Overview
Commits and releases over time
- Commits
- Releases
- Authors
Repository Explorer
No repositories match that filter.
128 commits in all time
Jun 16, 2026 12:01 – Sep 14, 2026 12:01 UTC
Require the router to be in the group, and show where a forfeit goes (S7, S2)
**S7.** The S6 fix mirrored `_assert_group_is_clean` into the browser, and the mirror was faithful and insufficient. That guard checks hygiene - rekey, close, group fee - and hygiene is not what a hostile conversion violates. A plain asset transfer of the whole balance to a stranger carries no close, no rekey and an ordinary fee, and passed the mirror completely. The contract does not check `aamt` or `arcv` either, and does not need to: on a routed group the rest of it checks - the input proven spent, the co-signed floor, the pinned pools - and none of that runs unless the router is called. So the mirror had copied the cheap outer shell and left out the part doing the work. The two exempt entry points say the same thing from the other side: `pool_budget` and `verify_discount` skip the hygiene guard precisely because they ride alongside a route, so hygiene alone was never the safety argument. `routedGroupProblems` now also requires a call to the router whose ARC-4 selector is not one of those two - "calls the router" would have passed `[pool_budget, transfer-to-attacker]`, which calls the router and is checked by nothing. The app id comes from `data-router-app`, handed down by the view and overridable by a setting, with the same number in the widget as a fallback so a missing attribute cannot switch the rule off. Not from the plan response: an id the engine supplied would make the check agree with whatever the engine wanted, which is S2 for the third time. The two excluded selectors are recomputed from the method signatures by verify-sweep.sh rather than trusted as constants. All four router groups in the audit's evidence carry non-exempt selectors, so the rule accepts every conversion that has actually executed. It also caught a test that had gone quiet. "Accepting implies no transaction closes or rekeys" became vacuous the moment this landed - no corpus transaction is an application call, so every generated group was refused for that reason and the implication was never exercised. It now prefixes a real route call and asserts that something really was accepted. **S2 recommendation 2.** The row showed unit, id, badge, value and reason and never the address the tokens went to, so on the one disposition that gives something away the destination was the single fact the reader was not shown. `destinationLabel` is the pure half, tested; `renderLine` only appends it. A close says plainly that nothing leaves, because a blank cell there reads as a missing fact rather than as reassurance. The audit is explicit that this complements the chain lookup rather than replacing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqeQenXa9oWnLCWEvuiy8B
4fe081b9
review/S2-hardening
6/331 ++ 5 --
Check the conversion path too, and stop a creator lookup hanging (S6)
`signAction` decided whether to inspect a group by reading `action.kind` out of the same response that carried the bytes. Every honest conversion carries a router call the contract checks - but an assertion inside an application only runs if the application is called, so a group labelled `convert` with no application call in it was refused by nobody: not by this file, which returned early, and not by `_assert_group_is_clean`, which was never in the group to object. S2 was a reference value the engine supplied; this was the switch deciding whether any checking happened. `routedGroupProblems` mirrors that guard where it always runs - no rekey, no close, no aclose, and the group's fee total against the contract's own MAX_GROUP_FEE rather than a number picked here. Mirroring cannot refuse a group the contract would accept. The decoder risk this raised is settled with evidence rather than reasoning. The seven groups in the audit's evidence/ were re-encoded from what the indexer returned and run through the shipped decodeMsgpack: 97 of 97 decode, application calls included, using only tags already supported. They are now a fixture, so the accepting side of this rule is tested against traffic that executed on mainnet instead of fixtures written to pass - and the ceiling has fourteen times the headroom it needs, the dearest real group paying 71,000 against 1,000,000. Writing it found something. "A conversion goes through the quote-signed path" passed [CLOSE_TO_SELF] as its group, a stand-in chosen because the path did not look at it. The new rule looked and refused it, correctly. That test now uses a real convert group. CREATOR_LOOKUP_TIMEOUT closes the one failure on the S2 path that neither refused nor accepted: algosdk v3 sets no timeout, so a node that never answered left the reader on a spinner with no prompt and no error. Ten seconds, then null, which is the refusal the unreachable node already produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqeQenXa9oWnLCWEvuiy8B
ffe76831
review/S2-hardening
4/495 ++ 12 --
Bind the forfeit predicate to one function, and issue the lookups together
The S2 fix rests on an invariant that spans two functions: a transaction may close somewhere other than the sweeper's own account only when the plan calls the holding a forfeit, and that same condition is what sends the destination to the chain to be confirmed. Both spelled `Number(x.amount) !== 0` out separately, so nothing failed if one of them changed - `closeOutProblems` would keep accepting a forfeit that `forfeitTargetProblems` had quietly stopped looking up. That is S2 reopening with a green suite. `isForfeit` is now the single predicate, sitting beside `planLines`: that one decides which lines are readable, this one decides what a readable line means. A test crosses both halves over the amount shapes an engine can actually send. Mutating one side to `> 0` fails three of them; before, it failed nothing. The lookups also move into one `Promise.all` over the distinct asset ids, decoding once up front. A group of sixteen forfeits waited sixteen times the node's latency before the wallet prompt opened, because the await sat in the compare loop. The rejection-to-null fold moved with it, so "could not be confirmed" is still the single refusal path. The compare loop becoming a `.map` is why the group side now gets the shape guard the `described` side has had since `planLines`. Three docstrings had drifted from the code and are corrected rather than left to mislead the next reader of a security control: the fee is bounded per transaction only, and `summaryFigures` reports the planner's figures without verifying them - a planner reporting zero fees renders "0.00 ALGO" unchallenged, and what bounds the loss is MAX_CLOSE_OUT_FEE on the bytes being signed. `recoverable` genuinely is net of fees; the planner subtracts them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqeQenXa9oWnLCWEvuiy8B
2dfc2188
review/S2-hardening
2/181 ++ 34 --
Stop the login dialog's tabs from hiding the historic charts
`mainHistoric` delegated from `[role=tablist]`, and this page has two of
them: base.html's login dialog is built to the same shape. Clicking
Social or Wallet in that dialog ran `tabShow("modal-tab-social")`, which
found the panel, decided none of the four `.historic-tab-panel`s was it,
and hid all of them -- charts gone when the reader closed the dialog.
The `aria-selected` sweep was unscoped for the same reason, and that one
ran unprompted: `showBars()` fires on load and on every unlock, and it
cleared the flag on the dialog's Log in tab each time.
Two locks now. The delegation is scoped to `.historic-tabs`, as the
stylesheet has always been, and `tabShow` requires the id to name a
`.historic-tab-panel` rather than merely to exist.
Found while covering the one uncovered branch in `tabClick`, the `|| ""`
fallback for a tab with no href. A test for it existed and had never
touched the anchor it appended: it clicked `$('[role="tab"]').last()`,
which is the dialog's Wallet tab, not the stray one at the end of the
widget's own tablist. It now clicks by id.
Six tests around the chain, all through the delegated click. The
isolation test also asserts the dialog's click was not default-prevented:
that is the only effect with no second lock behind it, and without it
reverting the delegation scoping still passed -- the panel-class guard
masks it, since neither leaves a trace in the DOM.
historic.js: 100% statements, branches, functions and lines. 105 jest
tests, 107 python tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LdZ42323XxKzzpGVdzj4C
79d2a924
review/S2-hardening
2/98 ++ 9 --