Page MenuHomePhabricator

[Cashtab] Add and implement a standard for agora action deep links
ClosedPublic

Authored by bytesofman on Jul 17 2026, 23:20.

Details

Reviewers
Cain
Fabien
Group Reviewers
Restricted Owners Package(Owns No Changed Paths)
Restricted Project
Commits
rABC17e079e6fc85: [Cashtab] Add and implement a standard for agora action deep links
Summary

T3760

Extend pay.e.cash deep link functionality to include support for Agora buys. Deep links can specify both tokenId and a specific quantity. If quantity is not specified, user can edit quantity. Otherwise, following the model of webapp-opened txs, Cashtab fixes the param-specified quantities.

The user may "OK" or "Reject" the buy.

Test Plan

npm test

I tried links and accepting + rejecting on mobile / extension / web

user is presented with this kind of option:

image.png (658×705 px, 49 KB)

Diff Detail

Repository
rABC Bitcoin ABC
Lint
Lint Not Applicable
Unit
Tests Not Applicable

Event Timeline

There are a very large number of changes, so older changes are hidden. Show Older Changes

All three addressed:

Docs (BUY may select an offer): reworded the pay.mdx line — "The link may preselect an offer and prefill the form; it never submits or signs on its own" — so it no longer implies BUY does nothing but fill fields, while keeping the no-auto-sign guarantee.

Snap through prepareAcceptedAtoms: the deep-link fillable/affordability check and the prefilled amount now snap the requested quantity to a valid discrete accept amount per offer before pricing, matching the percentage buttons and the reprice effect. This fixes the boundary case where an unsnapped value could misjudge affordability or pick the wrong offer.

Reapply on quantity change: the reset effect now keys on [tokenId, prepopulateBuyQty], so routing to a different BUY deep link on the same token reapplies the new quantity instead of being blocked by the already-applied flag from the prior link.

Regression green, prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Bugs in your code are closer than they appear.

────────────────────────────────────────────────────────────────────────

minor [Security & Privacy]
→ ]8;;vscode://file//work/web/pay.e.cash/Dockerfile:3web/pay.e.cash/Dockerfile:3-7]8;;

New nginx.conf gets publicly served alongside being used as server
config.

The pre-existing COPY . /usr/share/nginx/html/ (Line 3) sweeps the
entire build context into the web root, so the newly added nginx.conf
(Line 6) ends up both as the active nginx config and as a publicly
downloadable file at https://pay.e.cash/nginx.conf (nginx's try_files
serves exact-path matches verbatim). This leaks internal server config
details unnecessarily.


🛡️ Proposed fix: exclude non-asset files from the image build context

Add web/pay.e.cash/.dockerignore:

Dockerfile
nginx.conf
README.md
.git

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/web/pay.e.cash/script.js:112web/pay.e.cash/script.js:112-115]8;;

Invalid action value silently falls through instead of surfacing an
error.

Unlike the invalid-tokenId and duplicate-parameter cases just above, a
present-but-unrecognized action (e.g. ?action=SELL) returns null
here, causing run() to fall through and show the blank landing page
instead of the "This link is not valid" error view. This is the exact
failure mode the surrounding comments explicitly guard against for
tokenId/duplicates.


🐛 Proposed fix

     const action = params.get('action')?.toUpperCase();
-    if (action !== 'LIST' && action !== 'BUY') {
+    if (action === undefined) {
         return null;
     }
+    if (action !== 'LIST' && action !== 'BUY') {
+        return {
+            action: null,
+            tokenId: null,
+            price: null,
+            quantity: null,
+            error: 'This token action link has an unrecognized action',
+        };
+    }

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1008cashtab/src/components/Agora/OrderBook/index.tsx:1008-1022]8;;

This reset does not reliably re-trigger the prepopulate effect.

setSelectedIndex(0) is a no-op when selectedIndex is already 0
(React bails out without re-rendering), and ref mutations never trigger a
render either. So when a user routes to a second BUY deep link for the
same token while the currently selected offer happens to be index 0 (the
common case), this effect fires but produces no re-render at all — the
"apply prepopulateBuyQty" effect below (which doesn't list
prepopulateBuyQty in its own deps, see comment on Lines 1107-1205) never
re-executes, and the new quantity is silently never applied. This directly
contradicts the intent described in this comment block.

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1107cashtab/src/components/Agora/OrderBook/index.tsx:1107-1205]8;;

Add prepopulateBuyQty to this effect's dependency array.

The effect calls getPrepopulateBuyAtoms(), which closes over
prepopulateBuyQty, but the dependency array is `[activeOffers,
selectedIndex, decimals, userLocale] — prepopulateBuyQty` is missing.
Combined with the reset effect's no-op setSelectedIndex(0) when already
0 (see comment on Lines 1008-1022), this effect's registered closure can
go stale and never re-run when a new BUY deep link's quantity arrives for
the same token, even though prepopulateBuyQtyApplied.current was
correctly reset to false.


🔧 Proposed fix

-    }, [activeOffers, selectedIndex, decimals, userLocale]);
+    }, [activeOffers, selectedIndex, decimals, userLocale, prepopulateBuyQty]);

Rest of the flow (offer-fill/affordability search, discrete-amount
snapping, and the "only reset on real selection change" guard) looks
correct.

────────────────────────────────────────
Review complete
4 findings ✔

Major 2
Minor 2

13 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/Dockerfile

... and 3 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Addressed all four findings, then did a full audit of the deep-link path and found (and fixed) three more things review hadn't caught:

Both majors: correct — the reset-effect approach couldn't re-trigger the apply. prepopulateBuyQty is now in the apply effect's dependency array, and the flag-reset effect (which runs first in the same commit) clears the applied flag on the same change. Added a regression test for the exact scenario: a second BUY deep link on the same token whose quantity fills at the already-selected non-zero offer now reapplies (testable via prop rerender, unlike the ws-refresh cases).

Dockerfile leak: fixed, with one deviation from the suggested .dockerignore — ignoring nginx.conf would break the COPY nginx.conf step, so .dockerignore covers Dockerfile/README.md/.git and a RUN rm drops nginx.conf from the web root while keeping it in the build context.

Unrecognized action: now surfaces an error view in both pay.e.cash and the Cashtab parser (a missing action still falls back silently — it may be a non-agora link; a present-but-unknown action is told to the user, preserving forward-compat). Spec updated to match.

Self-found in the audit: (1) the URL quantity was parsed through normalizeDecimalInput(qty, userLocale), which strips . as a thousands separator in comma-decimal locales — ?quantity=1.70 prefilled 170. It's now parsed as the canonical decimal the spec defines. (2) The LIST price had the same ×100 hazard through the locale-aware validators; it's now prefilled only when the user's locale reads the canonical string as written, else the field is left empty per the spec's invalid-value rule. (3) The prior reset effect's setSelectedIndex(0) could race the apply and wipe a just-prefilled amount; selection now resets only on token change. The spec's Parameters section now states numeric values are canonical .-decimal strings that MUST NOT be locale-parsed.

Regression green (167 tests across the touched suites), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Blink twice if you're a bug.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/deeplinks/index.ts:269cashtab/src/deeplinks/index.ts:269-285]8;;

Empty action= is not treated as absent, contradicting the spec.

Per the standard (doc/standards/agora-deeplink.md lines 47-51), A
parameter present with an empty value (for example price= with nothing
after it) is treated as absent. This general rule reads as applying to all
parameters, but the code only applies it to price/quantity (line
309-310). If action= is present but empty,
url.searchParams.get('action')?.toUpperCase() yields '' (not
undefined), so it falls into the "unrecognized action" branch and
surfaces 'This token action is not supported' instead of silently
falling back as a non-agora link (matching the "action missing" behavior).


🐛 Proposed fix to treat empty action as absent

-        const action = url.searchParams.get('action')?.toUpperCase();
-        if (action === undefined) {
+        const actionParam = url.searchParams.get('action') || null;
+        if (actionParam === null) {
             // No action at all: not an agora action link, fall back.
             return empty;
         }
+        const action = actionParam.toUpperCase();
         if (action !== 'LIST' && action !== 'BUY') {

────────────────────────────────────────
Review complete
1 finding ✔

Minor 1

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Fixed: an empty ?action= is now treated as absent — falling back like any non-agora link — in both the Cashtab parser and pay.e.cash, consistent with the spec's empty-value rule and with how empty price/quantity are already handled. (The token screen already treated an empty action as no action.) Added a parser test.

Tests green, prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) KōdoUsagi Gundam is my Japanese cousin. He's a bit more mecha.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/doc/standards/agora-deeplink.md:94doc/standards/agora-deeplink.md:94-102]8;;

Move the balance check to after the quantity is known.

The amount a user will enter for a fungible listing is unavailable before
the form is shown. Require ownership before prefilling, then revalidate
the entered quantity and balance before signing.


Proposed wording

-2. Confirm the user actually holds the token (and, where the token is fungible,
-   holds enough to list the amount the user enters).
+2. Confirm the user actually holds the token. For fungible tokens, revalidate
+   that the entered amount is available before signing.

────────────────────────────────────────────────────────────────────────

minor [Maintainability & Code Quality]
→ ]8;;vscode://file//work/doc/standards/agora-deeplink.md:211doc/standards/agora-deeplink.md:211-214]8;;

Document the BUY/order-book part of the reference implementation.

This currently describes only token resolution and listing-form
prefilling. The stack also applies BUY state and performs offer
selection/quantity normalization in
cashtab/src/components/Agora/OrderBook/index.tsx.


Proposed wording

-  whether it supports the requested action, and prefills the listing form.
+  whether it supports the requested action, prefills LIST state, and passes
+  BUY state to the order book for offer selection and quantity prefilling.
+- `cashtab/src/components/Agora/OrderBook/index.tsx` selects the BUY offer and
+  normalizes the accepted quantity.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:927cashtab/src/components/Agora/OrderBook/index.tsx:927-937]8;;

Unfillable deep-link quantities permanently disable affordable
auto-selection.

The apply effect sets prepopulateBuyQtyApplied.current = true on Line
1174 even when fillableIndex === -1, i.e. when no deep-link selection
was ever made. From then on this guard suppresses
setSelectedIndex(bestOfferIndex) on every refresh, so a subsequent
balance increase (which refetches offers and re-evaluates
isUnaffordable) no longer moves the user off an unaffordable offer.

Consider tracking "deep link actually chose an offer" separately from
"deep link consumed":


♻️ Suggested adjustment

-            if (!prepopulateBuyQtyApplied.current) {
+            if (prepopulateBuyQtySelectedIndex.current === null) {
                 setSelectedIndex(bestOfferIndex);
             }

Set prepopulateBuyQtySelectedIndex.current only in the `fillableIndex
!== -1` branch, and clear it alongside the other refs on token/quantity
change.

────────────────────────────────────────────────────────────────────────

minor [Data Integrity & Integration]
→ ]8;;vscode://file//work/doc/standards/agora-deeplink.md:115doc/standards/agora-deeplink.md:115-134]8;;

Define BUY quantity normalization for NFTs and fungible offers.

Document whether NFT quantities are rejected or ignored, how invalid or
precision-adjusted fungible quantities are normalized, and how
below-minimum or above-available requests are handled. Require the wallet
to display the final accepted quantity before confirmation.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Etokens/Token/index.tsx:844cashtab/src/components/Etokens/Token/index.tsx:844-865]8;;

Map XECX/Firma LIST deep links to sellSlp or remove the manual “List
token” action. The dropdown enables sellSlp for both tokens, and that
path creates a genuine Agora LIST; refusing the equivalent deep link is
inconsistent.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/web/pay.e.cash/script.js:283web/pay.e.cash/script.js:283-315]8;;

BUY fallback message omits the quantity, unlike LIST's price mention.

quantity is a valid BUY parameter (quantity: action === 'BUY' ? quantity
: null,), but the BUY branch of the summary text never surfaces it, while
the LIST branch does show price when present. Users following a BUY link
with a prefilled quantity get no indication of it on this fallback page.





💬 Proposed fix

 const showAgoraActionFallbackView = agoraAction => {
     currentBip21 = null;
-    const { action, tokenId, price } = agoraAction;
+    const { action, tokenId, price, quantity } = agoraAction;
     if (homeViewEl) {
         homeViewEl.classList.add('hidden');
     }
     if (fallbackViewEl) {
         fallbackViewEl.classList.remove('hidden');
     }
     if (fallbackTitleEl) {
         fallbackTitleEl.textContent =
             'Open this token action in a supported eCash wallet';
     }
     if (paymentSummaryEl) {
         paymentSummaryEl.textContent =
             action === 'LIST'
                 ? `List token ${previewTokenId(tokenId)} for sale${
                       price !== null ? ` at ${price} XEC` : ''
                   }. Your wallet checks the token and you confirm before anything is signed.`
-                : `Buy token ${previewTokenId(tokenId)}. Your wallet takes the price from the active offer, and you confirm before anything is signed.`;
+                : `Buy token ${previewTokenId(tokenId)}${
+                      quantity !== null ? ` (quantity ${quantity})` : ''
+                  }. Your wallet takes the price from the active offer, and you confirm before anything is signed.`;
     }

────────────────────────────────────────
Review complete
6 findings ✔

Minor 6

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Five of the six addressed; pushing back on one:

Unfillable quantity freezing auto-select: correct, good catch — "deep link consumed" and "deep link chose an offer" were conflated in one flag. Added a separate deepLinkOfferChosen ref, set only when the link actually selects an offer; the affordable auto-select guard now keys on it, so an unfillable quantity no longer disables re-selection on later refreshes. Also added the user-facing notice the spec called for ("No single offer can fill the requested quantity") with a test.

Spec/docs minors: LIST's balance check reworded to sign-time revalidation of the entered amount; the reference-implementation section now documents the OrderBook's offer-selection and quantity-snapping role; BUY quantity normalization is now defined (ignored on NFTs, ignored when invalid for the token's decimals, MAY snap to the offer's step with the final amount and total price shown before confirmation, inform-the-user when unfillable); pay.e.cash's BUY fallback now shows the quantity, symmetric with LIST's price.

XECX/Firma LIST "inconsistency": I'd leave this as-is — it's a false positive. The manual UI has no sellSlp path for these tokens: the Sell button routes XECX to redeemXecx and Firma to redeemFirma, and is labeled "− Redeem" rather than "− Sell" for them (Token/index.tsx). The deep-link LIST refusal mirrors the manual UI exactly, per the earlier review direction that these tokens are redeemed, not listed.

Tests green (104 across the touched suites, two new), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Configure, don't integrate. Implement technology choices for an application as configuration options, not through integration or engineering.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx:489cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx:489-497]8;;

Assert the documented fallback, not just the toast.

The comment claims the amount falls back to the auto-selected offer's min
accept, but nothing verifies it — the deepLinkOfferChosen === false path
stays uncovered.





💚 Proposed additional assertion

         expect(
             await screen.findByText(
                 'No single offer can fill the requested quantity',
             ),
         ).toBeInTheDocument();
+
+        // Falls back to the min accept amount of the auto-selected offer
+        const buyAmountInput = (await screen.findByPlaceholderText(
+            `Select buy qty ${CACHET_TOKEN_ID}`,
+        )) as HTMLInputElement;
+        await waitFor(() => expect(buyAmountInput.value).not.toBe('99999999'));
+        expect(buyAmountInput.value).not.toBe('0');

────────────────────────────────────────────────────────────────────────

critical [Stability & Availability]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:932cashtab/src/components/Agora/OrderBook/index.tsx:932-944]8;;

Stale selectedIndex can now survive an offer-list shrink and crash the
render.

The guard skips setSelectedIndex on every refetch once a deep link chose
an offer. If the deep link selected a non-zero index and that offer is
later taken/canceled (ws refresh or balance-triggered refetch),
renderedActiveOffers shrinks while selectedIndex stays out of range →
activeOffers[selectedIndex] is undefined at Line 718, and Line 719
(selectedOffer.token.atoms) throws during render. Line 1237 has the same
exposure. The previous unconditional setSelectedIndex(bestOfferIndex)
always kept the index in range.

Clamp with a functional update (the selectedIndex captured by this async
closure can be stale) and release the deep-link lock when its offer is
gone.





🐛 Proposed fix: clamp the selection when offers shrink

-            if (!deepLinkOfferChosen.current) {
-                setSelectedIndex(bestOfferIndex);
-            }
+            if (!deepLinkOfferChosen.current) {
+                setSelectedIndex(bestOfferIndex);
+            } else {
+                // A deep link owns the selection, but the selected offer may
+                // have been taken or canceled since. Never leave selectedIndex
+                // out of range, or activeOffers[selectedIndex] is undefined.
+                setSelectedIndex(prev => {
+                    if (prev < renderedActiveOffers.length) {
+                        return prev;
+                    }
+                    deepLinkOfferChosen.current = false;
+                    return bestOfferIndex;
+                });
+            }

────────────────────────────────────────
Review complete
2 findings ✔

Critical 1
Minor 1

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Both addressed:

Critical (stale selection after offer-list shrink): confirmed and fixed as proposed — the clamp is a functional update (the async fetch completion's captured selectedIndex is stale) that keeps the selection in range and releases the deep-link hold when its offer is gone, falling back to the best affordable offer. The exposure dated back to when the deep-link guard first started skipping the unconditional auto-select; refining the guard made it visible. Couldn't wrap an automated test around it — it needs a mid-session offer-list shrink via a ws/balance-triggered refetch, which MockChronik/MockAgora can't drive — but the clamp path runs on the ordinary refetches the existing tests exercise.

Test assertion: the unfillable-quantity test now also asserts the amount fell back to the auto-selected offer's default (waits out the initial '0', and confirms the unfillable quantity was not prefilled).

Tests green (104), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Nothing is more permanent than a temporary solution.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1225cashtab/src/components/Agora/OrderBook/index.tsx:1225-1231]8;;

The unfillable message conflates "too large" with "too expensive", and
never retries after a balance increase.

fillableIndex === -1 covers both an out-of-range quantity and one the
user simply cannot afford, yet the toast only mentions fill capacity —
confusing for a user who just needs more XEC. Separately,
prepopulateBuyQtyApplied is set before this branch, so once offers
refetch after a balance increase the link is never reapplied; only the
min-accept default is shown. Note this also means the comment at Lines
450-454 is accurate about auto-select but not about re-application.

Consider distinguishing the two causes (track whether any offer matched on
size) and gating the "unaffordable" case on a separate toasted ref so
the quantity can still be applied on a later refresh without re-toasting.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1168cashtab/src/components/Agora/OrderBook/index.tsx:1168-1192]8;;

findIndex can select the user's own or unacceptable offers.

activeOffers retains maker-owned offers (and isUnacceptable ones when
maker), per fetchAndPrepareActiveOffers at Lines 809-816. A BUY deep
link for a token the user also lists can therefore land on their own
offer: the UI renders "Cancel your offer" instead of a buy flow, and the
prefilled quantity is meaningless.


♻️ Skip offers that cannot be bought

             const fillableIndex = activeOffers.findIndex(offer => {
                 const { params } = offer.variant;
+                if (
+                    offer.isUnacceptable ||
+                    toHex(ecashWallet.pk) === toHex(params.makerPk)
+                ) {
+                    // Cannot buy our own offer, nor an unacceptable one
+                    return false;
+                }
                 const minAtoms = params.minAcceptedAtoms();

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Etokens/Token/index.tsx:844cashtab/src/components/Etokens/Token/index.tsx:844-865]8;;

LIST refusal for XECX/Firma contradicts the UI, which does offer listing
for exactly these tokens.

Lines 3376-3393 render a "List token" dropdown item only for
appConfig.vipTokens.xecx.tokenId and FIRMA.tokenId, which calls
setAction('sellSlp') and renders the partial list form. So these tokens
are listable on Agora in Cashtab; the deep link is the only path that
refuses with "cannot be listed". Either map LIST to sellSlp for them
(redeem stays the default action) or clarify why link-driven listing is
deliberately excluded.

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/deeplinks/index.ts:42cashtab/src/deeplinks/index.ts:42-44]8;;

Misleading tokenId docstring — it can be null while still an agora
action link.

The comment says tokenId is null only "if this is not an agora action
link," but the parser returns tokenId: null together with a non-null
action whenever the tokenId is missing/invalid or a parameter is
repeated (see the invalid-tokenId and repeated-parameter branches). Only
action === null reliably signals "not an agora action link." A
downstream consumer relying on this docstring could use the wrong
null-check and silently swallow a validation error instead of surfacing
it, which conflicts with the standard's intent
(doc/standards/agora-deeplink.md, "the wallet MUST surface a validation
error rather than silently falling back").


Suggested docstring fix

-    /** Lowercase hex tokenId the action applies to, or null if this is not an agora action link */
+    /** Lowercase hex tokenId the action applies to. May be null even when `action` is set (e.g. a missing/invalid tokenId, or a repeated-parameter error); use `action === null` to check whether this is an agora action link at all. */
     tokenId: string | null;

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/web/pay.e.cash/script.js:137web/pay.e.cash/script.js:137-146]8;;

Normalize token IDs before validation.

TOKEN_ID_REGEX rejects uppercase or mixed-case hexadecimal IDs.
Lowercase the query value before validating and forwarding it so Cashtab
receives its required lowercase format.

────────────────────────────────────────
Review complete
5 findings ✔

Major 1
Minor 4

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Four fixed, two declined with reasons — plus two hardenings from my own pass that the reviewed diff predated (deep-link selection skips the wallet's own offers, with a maker-wallet test; the repeated-param error preserves b=1).

Major (docstrings): correct — tokenId/action nullability didn't match the error contract. Both docstrings now say it plainly: consumers check error first (as App.tsx does); only a result with null error, action, and tokenId means "not an agora action link."

XECX/Firma LIST: you're right, and my earlier "false positive" reply was wrong — I verified the Sell button (which is Redeem for these tokens) but missed the "List token" dropdown item that offers sellSlp for exactly XECX and Firma. A LIST deep link now maps to the same sellSlp flow, prefilled, and the two tests assert the form instead of the refusal.

Own/unacceptable offers: the selection scan now skips isUnacceptable offers explicitly in addition to the wallet's own (maker) offers.

Unfillable message: now distinguishes "Insufficient balance for the requested quantity" from "No single offer can fill the requested quantity."

Declined — re-applying after a balance increase: a consumed link that re-fires on a background refetch would overwrite whatever amount the user has typed in the meantime; a deep link is an entry intent, not a standing order. The ref comment now states the one-shot semantics explicitly so it can't be misread.

Declined — lowercasing tokenIds in pay.e.cash: strict lowercase was the earlier direction (matching isValidTokenId), and normalizing only on the fallback page would make the same link succeed there but error in Cashtab's own parser when the app intercepts it. If leniency is preferred I'd add it to both parsers together.

Tests green (105 across the touched suites), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Bugs were harmed in the making of this code review.

────────────────────────────────────────────────────────────────────────

minor [Security & Privacy]
→ ]8;;vscode://file//work/cashtab/src/components/Etokens/Token/index.tsx:862cashtab/src/components/Etokens/Token/index.tsx:862-903]8;;

Bind blacklist state to the token it was fetched for.

Token is reused when React Router changes /token/:tokenId because the
route renders ` without a key`. During navigation from token A to token
B, the deep-link effect can observe B’s tokenId with A’s stale
isBlacklisted === false; the reset to null is not visible until the
next render. A blacklisted B can therefore open the requested action
before its blacklist response arrives, and appliedDeepLinkKey prevents
reevaluation. Track the token associated with the blacklist result and
treat it as null when it does not match the current tokenId.

────────────────────────────────────────
Review complete
1 finding ✔

Minor 1

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Fixed: the blacklist answer is now bound to the token it was fetched for. A blacklistCheckedForTokenId ref is stamped with the fetch closure's captured tokenId at both set-sites (including the error-assume-false path), and the deep-link gate requires it to match the rendered tokenId — a mismatched answer reads as still-loading, and the effect re-evaluates when the current token's own answer arrives (isBlacklisted is a dependency). This closes the reused-component window where /token/b's deep link could apply against /token/a's stale isBlacklisted === false, with appliedDeepLinkKey then blocking re-evaluation. Couldn't add an automated test — the harness mounts fresh per test and can't drive an in-place route change — but the existing blacklist-refusal test covers the gate's normal path.

Tests green (157 across the touched suites), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Childhood dream: become a code reviewer. Adult reality: became a code reviewer. Childhood dream achieved.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/deeplinks/index.ts:250cashtab/src/deeplinks/index.ts:250-255]8;;

Restrict the accepted path to the documented forms.

The regular expression accepts /token// and any number of trailing
slashes. The comment and standard permit only /token and /token/.

Use explicit path checks. Add a regression test for /token//.






Proposed fix

-            // Accept /token and /token/
-            url.pathname.replace(/\/+$/, '') !== '/token'
+            // Accept /token and /token/
+            (url.pathname !== '/token' && url.pathname !== '/token/')

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/deeplinks/index.ts:267cashtab/src/deeplinks/index.ts:267-279]8;;

Reject repeated b parameters.

The duplicate-parameter check excludes b. URLSearchParams.get('b')
selects the first value, so b=0&b=1 produces order-dependent
browser-return behavior.

Treat repeated b as an invalid parameter. Add a regression test for this
case.






Proposed fix

         const b = url.searchParams.get('b');
+        if (url.searchParams.getAll('b').length > 1) {
+            return {
+                ...empty,
+                error: 'This token action link has a repeated parameter',
+                returnToBrowser: false,
+            };
+        }
 
         // A parameter given more than once is ambiguous (which value wins?). On

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:945cashtab/src/components/Agora/OrderBook/index.tsx:945-963]8;;

Track the deep-link offer by identity, not by index.

The hold keeps prev whenever the index is still in range. Offers are
re-sorted on every refresh. If the deep-link offer is taken and a
different offer enters the list, the index can stay in range and then
point to another offer. The deep-link amount is preserved, so the user
reviews a quantity that was validated against a different offer. Price
recomputes, so this is a selection substitution rather than a wrong price.

Store the chosen offer outpoint in the ref and resolve the index from it
on each refresh.





🔧 Sketch of identity-based tracking

-    const deepLinkOfferChosen = useRef<boolean>(false);
+    // Outpoint of the offer a BUY deep link selected, or null when no deep
+    // link selected an offer
+    const deepLinkOfferOutpoint = useRef<null | string>(null);

-            if (!deepLinkOfferChosen.current) {
+            if (deepLinkOfferChosen.current === null) {
                 setSelectedIndex(bestOfferIndex);
             } else {
-                setSelectedIndex(prev => {
-                    if (prev < renderedActiveOffers.length) {
-                        return prev;
-                    }
-                    deepLinkOfferChosen.current = false;
-                    return bestOfferIndex;
-                });
+                const heldIndex = renderedActiveOffers.findIndex(
+                    offer =>
+                        `${offer.outpoint.txid}:${offer.outpoint.outIdx}` ===
+                        deepLinkOfferOutpoint.current,
+                );
+                if (heldIndex === -1) {
+                    deepLinkOfferOutpoint.current = null;
+                    setSelectedIndex(bestOfferIndex);
+                } else {
+                    setSelectedIndex(heldIndex);
+                }
             }

This also removes the ref mutation inside the state updater, which React
may invoke more than once.

────────────────────────────────────────────────────────────────────────

minor [Maintainability & Code Quality]
→ ]8;;vscode://file//work/web/docs.e.cash/content/pay.mdx:72web/docs.e.cash/content/pay.mdx:72]8;;

Fix heading capitalization for consistency.

Line 23 uses "Agora action links" (capitalized), but line 72 uses "Example
agora action links" (lowercase). "Agora" is a proper noun in this doc; use
the same capitalization in both headings.





✏️ Proposed fix

-## Example agora action links
+## Example Agora action links

────────────────────────────────────────
Review complete
4 findings ✔

Minor 4

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

All four addressed:

Identity-based offer hold: implemented as sketched — the deep-link hold now stores the chosen offer's outpoint and resolves it against each refreshed list, so a re-sort can't silently substitute a different offer at the same index; if the held offer is gone the hold is released and selection falls back to the best affordable offer. This also removes the ref mutation from inside the state updater.

Path forms: both parsers now accept exactly /token and /token/ (/token// falls through), with a regression test.

Repeated b: rejected like any repeated parameter in both parsers, and since an ambiguous b can't be trusted, the error does not honor return-to-browser. Test added.

Docs: heading capitalization fixed.

Tests green (106 across the touched suites), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) It is a truth universally acknowledged, that a single developer in possession of a good feature, must be in want of an AI code review.

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:954cashtab/src/components/Agora/OrderBook/index.tsx:954-968]8;;

Release the deep-link hold when the user selects an offer manually.

The hold is cleared only on token change, quantity change, or when the
held offer disappears. An offer row click calls setSelectedIndex(index)
and leaves deepLinkOfferOutpoint.current set. The next refresh
(websocket trade at Line 998, or balance change at Line 1319) then
resolves the held outpoint and forces the selection back to the deep-link
offer. The buy-amount effect sees a selection change and overwrites the
user's quantity with that offer's min accept amount.

Clear the ref when the user picks a row.





🐛 Proposed fix: clear the hold on manual selection

Add a handler and use it for the row click (outside the selected range,
near Line 1656):

+    const handleSelectOffer = (index: number) => {
+        // A manual selection replaces any deep-link selection, so release the
+        // hold and let refreshes follow the user's choice
+        deepLinkOfferOutpoint.current = null;
+        setSelectedIndex(index);
+    };

-                                            onClick={() =>
-                                                setSelectedIndex(index)
-                                            }
+                                            onClick={() =>
+                                                handleSelectOffer(index)
+                                            }

────────────────────────────────────────
Review complete
1 finding ✔

Major 1

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Fixed as proposed: a manual row selection now releases the deep-link hold (handleManualSelectOffer clears the outpoint ref before setSelectedIndex), so subsequent refreshes follow the user's choice. Fair catch on the interaction — this was a consequence of moving the hold to outpoint identity last round: the old boolean merely skipped auto-select on refresh (so a manual pick happened to survive), while the identity hold actively re-resolves the held offer, which made "manual selection supersedes the link" need to be explicit. It now is, with a comment stating the rule. Couldn't test the full click-then-refresh sequence (the harness can't drive a mid-session refetch), but the row-click path itself is exercised by the existing multi-offer tests.

Tests green (113 across the touched suites), prettier + arc lint clean.

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Don't worry if it doesn't work right. If everything did, you'd be out of a job. - Mosher's Law of Software Engineering

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Etokens/Token/index.tsx:958cashtab/src/components/Etokens/Token/index.tsx:958-962]8;;

Add the token balance to the dependency list.

The effect reads tokenBalance, which comes from tokens.get(tokenId).
The dependency list contains initialUtxoSyncComplete but not
tokenBalance or tokens. If initialUtxoSyncComplete turns true in a
render before tokens contains this token's balance, the effect runs once
with tokenBalance === undefined. It then shows "You do not hold ..." and
marks the link as applied, so a valid LIST link for a held token is
refused and never retried.


🐛 Proposed fix

     }, [
         cachedInfoLoaded,
         isBlacklisted,
         initialUtxoSyncComplete,
+        tokenBalance,
         searchParams,
         tokenId,
     ]);

Also applies to: 1019-1025

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1286cashtab/src/components/Agora/OrderBook/index.tsx:1286-1298]8;;

Track the last acted-on offer by outpoint, not by index.

deepLinkOfferOutpoint holds the offer by identity, but
buyAmountSelectedIndex records an index. fetchAndPrepareActiveOffers
re-sorts offers on every refresh and then calls
setSelectedIndex(heldIndex). If the held offer moves position, for
example when a cheaper offer appears or the spot offer is taken,
selectedIndex changes while the selected offer stays the same.
selectionChanged then evaluates to true, and this block resets the
amount to minAcceptedAtoms, which wipes the deep-link prefill or an
amount the user typed.

Compare identities so a re-sort does not count as a selection change.


🐛 Proposed fix: key selection tracking on the offer outpoint

-        const selectionChanged =
-            buyAmountSelectedIndex.current !== selectedIndex;
-        buyAmountSelectedIndex.current = selectedIndex;
+        const selectedOutpointKey = getOfferOutpointKey(
+            activeOffers[selectedIndex],
+        );
+        const selectionChanged =
+            buyAmountSelectedOutpoint.current !== selectedOutpointKey;
+        buyAmountSelectedOutpoint.current = selectedOutpointKey;
         if (!selectionChanged) {
             return;
         }

Rename the ref at its declaration and at the two other assignment sites
(Lines 449, 1081, and 1250) accordingly:

-    const buyAmountSelectedIndex = useRef<null | number>(null);
+    const buyAmountSelectedOutpoint = useRef<null | string>(null);

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/doc/standards/agora-deeplink.md:47doc/standards/agora-deeplink.md:47-51]8;;

Clarify precedence between the repeated-parameter rule and the
missing-action fallback rule.

The doc states two rules without ordering them: a repeated
action/tokenId/price/quantity makes a link invalid (lines 47-49),
and a missing action makes the wallet fall back to default behavior
(lines 67-68). It does not say which applies when both conditions hold at
once, for example a /token link with no action and a duplicated
tokenId.

The reference implementation (cashtab/src/deeplinks/index.ts) resolves
this by checking repeated parameters before checking whether action is
present, so that case surfaces a validation error rather than falling
back. Since other wallets implement this standard independently, state the
intended precedence explicitly so all implementations agree.





Also applies to: 67-77

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/web/pay.e.cash/script.js:100web/pay.e.cash/script.js:100-122]8;;

Check for a missing action before the duplicate-parameter scan.

The duplicate-parameter loop runs before the code confirms that action
is present. A /token URL with no action but a duplicated tokenId,
price, quantity, or b value now returns the "repeated parameter"
error, even though the comment at Line 121 states that a link with no
action should fall back silently (return null). Check
!params.has('action') first, so unrelated duplicate parameters on a
non-agora /token visit do not surface a spurious error.





🐛 Proposed fix to reorder the checks

+    // No action at all: this is not an agora action link. Check this before
+    // the duplicate-parameter scan below, so an unrelated repeated parameter
+    // on a non-agora /token visit does not surface a spurious error.
+    if (!params.has('action')) {
+        return null;
+    }
+
     // A parameter given more than once is ambiguous (which value wins?). On the
     // dedicated agora path this is a broken agora link, so surface it rather
     // than silently taking the first value or falling back.
     for (const key of ['action', 'tokenId', 'price', 'quantity', 'b']) {
         if (params.getAll(key).length > 1) {
             return {
                 action: null,
                 tokenId: null,
                 price: null,
                 quantity: null,
                 error: 'This token action link has a repeated parameter',
             };
         }
     }
 
     // An empty ?action= is treated as absent, like every other empty-valued
     // parameter (the spec's empty-value rule), so it falls back rather than
     // surfacing an unrecognized-action error
     const action = (params.get('action') || null)?.toUpperCase();
     if (action === undefined) {
-        // No action at all: not an agora action link, fall back.
+        // action was present but empty: not an agora action link, fall back.
         return null;
     }

────────────────────────────────────────
Review complete
4 findings ✔

Major 3
Minor 1

14 files reviewed:

  • cashtab/src/components/Agora/OrderBook/__tests__/index.test.tsx
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts
  • cashtab/src/deeplinks/index.ts
  • doc/standards/agora-deeplink.md
  • web/docs.e.cash/content/pay.mdx
  • web/pay.e.cash/.dockerignore

... and 4 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

bytesofman edited reviewers, added: Cain; removed: bytesofman.

I'll take this the last mile.

Going forward, wait for ABC review before addressing coderabbit ... unless the coderabbit comment is something you know is an issue.

I think I was the one who told you to address the coderabbit stuff so my bad.

Follow pattern of app-created txs; show a modal that summarizes the specific tx without allowing user edits, autoclose and return to the calling app on accept or reject

Tail of the build log:

/work /work/abc-ci-builds/ai-review
Connecting to CodeRabbit... 0s elapsed
Preparing review... 1s elapsed

  ✗ Review limit reached

  Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).
  You can wait 58 seconds for the limit to reset.
  https://app.coderabbit.ai/settings/billing?tab=usage&orgId=79839cd4-7849-4bd5-8d6d-5ecaf453356e

Build ai-review failed with exit code 1
bytesofman edited the test plan for this revision. (Show Details)

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Walking on water and developing software from a specification are easy if both are frozen. - Edward V. Berard

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:961cashtab/src/components/Agora/OrderBook/index.tsx:961-968]8;;

Fetch offers after a token change when noWebsocket is enabled.

On a tokenId change, the effect at Lines 932-959 sees the previous
non-null activeOffers and skips the fetch. Its cleanup then clears the
offers. When noWebsocket is true, ws remains null and activeOffers
is not a dependency, so no later effect fetches the new token. The new
token shows no active offers.

Fetch the new token's offers in the token-change path, or make the loading
effect react to clearing activeOffers.

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1054cashtab/src/components/Agora/OrderBook/index.tsx:1054-1090]8;;

Preserve the selected offer across offer refreshes.

The refresh routine at Lines 874-883 always calls
setSelectedIndex(bestOfferIndex). If the user selected another offer, a
websocket or balance refresh changes selectedIndex. This effect then
treats the refresh as a selection change and resets the typed quantity.

Auto-select only for the initial token load. Preserve the selected offer
by a stable offer identifier when the offers array refreshes.

────────────────────────────────────────────────────────────────────────

minor [Stability & Availability]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/DeepLinkBuy/index.tsx:301cashtab/src/components/Agora/DeepLinkBuy/index.tsx:301-312]8;;

Skip the reload while the success modal is open.

balanceSats is a dependency of this effect. A completed take changes the
balance, so the effect re-runs and re-queries agora while the success
modal is displayed. The taken offer is gone, so load can set
selectedOffer to null and loadError to `'No active offers for this
token'` underneath the modal, and it competes with the auto-close timer.

Add an early return while a success is displayed or a send is in flight.






🛠️ Proposed guard

         const load = async () => {
             if (
                 agora === null ||
                 ecashWallet === null ||
-                typeof decimals === 'undefined'
+                typeof decimals === 'undefined' ||
+                showSuccessModal ||
+                isSending
             ) {
                 return;
             }

Add showSuccessModal and isSending to the dependency array.



Also applies to: 426-435

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Etokens/Token/index.tsx:901cashtab/src/components/Etokens/Token/index.tsx:901-906]8;;

Enforce the spec rules that this file is cited as implementing.

doc/standards/agora-deeplink.md line 90 states that a quantity
parameter on a LIST is invalid and that the wallet MUST surface a
validation error. This effect ignores quantity for LIST and proceeds to
prefill. web/pay.e.cash/script.js line 162 already rejects that
combination, so the two layers disagree, and the doc names this file the
reference implementation.

Line 69 of the same document states that an unrecognized action SHOULD
be reported to the user. Lines 902-906 return silently.






🛠️ Proposed fix for the LIST rule

         // LIST
+        // A LIST takes no quantity; the amount is chosen in the listing form.
+        if (searchParams.get('quantity')) {
+            toast.error('A list link cannot specify a quantity');
+            return;
+        }
         const listAction = getListActionForToken();

As per path instructions: the standard document in this change set defines
these as MUST/SHOULD wallet behavior.


Also applies to: 962-975

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/DeepLinkBuy/index.tsx:593cashtab/src/components/Agora/DeepLinkBuy/index.tsx:593-621]8;;

Any click inside the success modal dismisses it.

SuccessModalOverlay has an onClick handler. Clicks inside
SuccessModalContent bubble to the overlay, so clicking the message or
the icon calls closeOrNavigate. The e.target === e.currentTarget check
on the content element does not prevent this, because it does not stop
propagation.

Stop propagation on the content element so that only the backdrop
dismisses.






🛠️ Proposed fix

-                    <SuccessModalContent
-                        onClick={e => {
-                            if (e.target === e.currentTarget) {
-                                closeOrNavigate();
-                            }
-                        }}
-                    >
+                    <SuccessModalContent onClick={e => e.stopPropagation()}>

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/doc/standards/agora-deeplink.md:201doc/standards/agora-deeplink.md:201-219]8;;

Correct the Cashtab example URLs to the hash route.

Cashtab Web uses hash routing. web/pay.e.cash/script.js line 8 builds
links from https://cashtab.com/#/token. The examples here omit #/, so
a reader who copies them gets a URL that does not resolve to the token
screen.






📝 Proposed fix

-    `https://cashtab.com/token/<nftTokenId>?action=LIST&price=5000`
+    `https://cashtab.com/#/token/<nftTokenId>?action=LIST&price=5000`

Apply the same change to the other three examples and to the route
reference on line 223.

────────────────────────────────────────
Review complete
6 findings ✔

Major 2
Minor 4

18 files reviewed:

  • cashtab/extension/public/manifest.json
  • cashtab/package.json
  • cashtab/src/components/Agora/DeepLinkBuy/__tests__/index.test.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/index.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/styled.ts
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts

... and 8 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Address CodeRabbit follow-ups on DeepLinkBuy + Token validation

  • Skip offer reload while take is in flight or success modal is open
  • stopPropagation on success modal content (backdrop-only dismiss)
  • Toast for LIST+quantity and unrecognized action (spec MUST/SHOULD)
  • Doc examples use Cashtab hash routes; reference impl mentions DeepLinkBuy

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Prototype to learn. Prototyping is a learning experience. Its value lies not in the code you produce, but in the lessons you learn.

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/web/pay.e.cash/script.js:312web/pay.e.cash/script.js:312-315]8;;

showAgoraActionFallbackView never removes the hidden class from
openWebLinkEl.

showAgoraActionErrorView adds hidden to openWebLinkEl, and
copyBip21ButtonEl is hidden in both views. The fallback view sets
textContent and href but does not call
openWebLinkEl.classList.remove('hidden'). If the element starts hidden
in the markup, or another view hid it, the "Open in Cashtab Web" link
stays invisible. Confirm the default state in index.html, or remove the
class explicitly.






🐛 Proposed defensive fix

     if (openWebLinkEl) {
+        openWebLinkEl.classList.remove('hidden');
         openWebLinkEl.textContent = 'Open in Cashtab Web';
         openWebLinkEl.href = buildCashtabWebTokenUrl(agoraAction);
     }

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/DeepLinkBuy/index.tsx:424cashtab/src/components/Agora/DeepLinkBuy/index.tsx:424-442]8;;

The offer-load effect can overwrite a user-edited quantity.

balanceSats is a dependency of this effect. The wallet balance changes
on any wallet update, for example an incoming transaction or a websocket
refresh. When that happens and no quantity is in the link, the effect
re-runs and calls setTakeTokenDecimalizedQty(...) with the offer
minimum. The user's typed or slid amount is then lost while they are
reviewing the trade.

Consider keeping the editable amount when the offer selection does not
change, for example by only setting the amount when selectedOffer is
null or the selected offer changed.






♻️ Sketch of a guard for the editable branch

                 const offer = prepared[editableIndex];
                 setSelectedOffer(offer);
                 setQtyLocked(false);
-                setTakeTokenDecimalizedQty(
-                    formatAmountFromWire(
-                        decimalizeTokenAmount(
-                            offer.variant.params.minAcceptedAtoms().toString(),
-                            decimals as SlpDecimals,
-                        ),
-                        userLocale,
-                    ),
-                );
+                // Do not clobber an amount the user already edited
+                setTakeTokenDecimalizedQty(previous =>
+                    previous !== ''
+                        ? previous
+                        : formatAmountFromWire(
+                              decimalizeTokenAmount(
+                                  offer.variant.params
+                                      .minAcceptedAtoms()
+                                      .toString(),
+                                  decimals as SlpDecimals,
+                              ),
+                              userLocale,
+                          ),
+                );

────────────────────────────────────────
Review complete
2 findings ✔

Major 1
Minor 1

18 files reviewed:

  • cashtab/extension/public/manifest.json
  • cashtab/package.json
  • cashtab/src/components/Agora/DeepLinkBuy/__tests__/index.test.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/index.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/styled.ts
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts

... and 8 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

latest coderabbit stuff are ... very niche edge cases and imo too in the weeds. will need to see how this feature behaves in production.

Fabien requested changes to this revision.Tue, Aug 11, 14:05
Fabien added inline comments.
cashtab/src/components/Agora/DeepLinkBuy/index.tsx
114–123 ↗(On Diff #60798)

Is activeOffer.isUnacceptable intialized ? If not then the state is undefined in the continue case. Also see the suggested simplification

129 ↗(On Diff #60798)

I don't understand this

141–144 ↗(On Diff #60798)
145–147 ↗(On Diff #60798)
158 ↗(On Diff #60798)

Note that order is not guaranteed here, a and b can be equal here

265 ↗(On Diff #60798)

why do you need the timeout ? close is a sync call

428 ↗(On Diff #60798)

I doubt any of this make sense. If the one-shot link was not successful there is no point updating unless the user clicks the link again imo

cashtab/src/components/Etokens/Token/index.tsx
287 ↗(On Diff #60798)

I read the comments and code about this but can't make sense out of it, can you please explain?

cashtab/src/deeplinks/index.ts
282 ↗(On Diff #60798)

not sure it's worth it tbh, it's a malformed link so returning to the source that generated the malformed link (and losing the info of what went wrong) is not necessarily the best

308 ↗(On Diff #60798)

Same here, I would just init it to false and set it true if all the basic checks passed

web/pay.e.cash/script.js
96 ↗(On Diff #60798)

We should start making this more generic, and have a function check for a single one of these params and use in all parsing functions

This revision now requires changes to proceed.Tue, Aug 11, 14:05

the real issue here is that I accidentally published app + extension at 5.24.0, this isn't really the fix for that

meant to abandon the another diff

This revision now requires changes to proceed.Wed, Aug 12, 06:38
bytesofman added inline comments.
cashtab/src/components/Agora/DeepLinkBuy/index.tsx
129 ↗(On Diff #60798)

Re-implementing a general XECX condition where Cashtab does not show offers at prices that are not 1 XECX === 1 XEC unless the offer was created by the user, so that the user can cancel their own offers no matter the price.

158 ↗(On Diff #60798)

that's ok for our purposes here, since we really only care about getting the cheapest price as the spot price. It could be any offer with this price.

We could refine later to also consider ideal quantities but this is already a pretty heavy diff.

265 ↗(On Diff #60798)

If we don't have the timeout, tabs that do close will flash the dismissToTokenPage() nav that is intended only for the tabs that do NOT close

this makes sure we close tabs and do not do anything with nav on the tabs that do successfully close

that said ... still seems overkill and I'm not sure if this actually happens or if it's just an AI over-correction. Will pull it out for now. Could improve this later if issue is observed. In practice, no one is expected to type in these links directly, use is always expected to be from deep links or links that open the extension, where window.close() will work. But someone COULD copy paste the link in .. it's how I test for example.

428 ↗(On Diff #60798)

yeah this is AI-driven over-analysis / good idea fairy stuff, removing the balance dep

cashtab/src/components/Etokens/Token/index.tsx
287 ↗(On Diff #60798)

i think this came from coderabbit review. it's a potential issue that is not necessarily related to this diff (user navigates from one token page directly to another token page, briefly gets a stale isBlacklisted result)

removing

web/pay.e.cash/script.js
96 ↗(On Diff #60798)

Yes, I think we still need to do some kind of bip21 library that can then be shared by Cashtab and pay.e.cash

For now I think this is still ok. The pay.e.cash logic is just for fallbacks when the app does not open.

bytesofman marked 6 inline comments as done.

Address Fabien review feedback

  • Simplify DeepLinkBuy offer prep (isUnacceptable, inlines, XECX/FIRMA comments)
  • One-shot offer load; web dismiss matches SendXec (window.close only)
  • Drop blacklistCheckedForTokenId race plumbing
  • Agora parse errors never honor returnToBrowser

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) Veni, Vidi, Codici Fixi. I came, I saw, I fixed the code.

────────────────────────────────────────────────────────────────────────

major [Maintainability & Code Quality]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/DeepLinkBuy/index.tsx:98cashtab/src/components/Agora/DeepLinkBuy/index.tsx:98-160]8;;

prepareBuyableOffers duplicates OrderBook.fetchAndPrepareActiveOffers.

The filter rules (unacceptable, XECX 1:1, FIRMA minter, affordability,
spot price, sort) repeat the logic in
cashtab/src/components/Agora/OrderBook/index.tsx Lines 750-843. A future
rule change must be applied twice. Consider extracting a shared helper,
for example in components/Agora/helpers, and using it in both
components.

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/DeepLinkBuy/index.tsx:229cashtab/src/components/Agora/DeepLinkBuy/index.tsx:229-257]8;;

Reject can leave the user stuck on web.

closeOrNavigate calls window.close() for every web case. Browsers
ignore window.close() for a tab the user opened directly, for example by
clicking https://cashtab.com/#/token/?action=BUY from a site or by
typing the URL. In that case Reject does nothing and the confirm screen
stays. The comment on Lines 230-233 states that dismissToTokenPage
handles "when the tab cannot be closed (typed URL)", but no web path calls
it.

Add a fallback so the screen always dismisses.


🛠️ Proposed fallback after a failed close

     const closeOrNavigate = useCallback(() => {
         setShowSuccessModal(false);
         if (Capacitor.isNativePlatform()) {
             if (returnToBrowser) {
                 CapacitorApp.exitApp();
                 return;
             }
             dismissToTokenPage();
             return;
         }
         window.close();
+        // window.close() is a no-op for a tab the user opened directly.
+        // If we are still here, fall back to the clean token page.
+        setTimeout(() => {
+            if (!window.closed) {
+                dismissToTokenPage();
+            }
+        }, 250);
     }, [dismissToTokenPage, returnToBrowser]);

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:1044cashtab/src/components/Agora/OrderBook/index.tsx:1044-1052]8;;

The pricing effect omits decimals, userLocale, and balanceSats from
its dependencies.

The effect body reads decimals, decimalizedTokenQtyMin,
decimalizedTokenQtyMax, userLocale, and balanceSats, but the
dependency array is `[takeTokenDecimalizedQty, activeOffers,
selectedIndex]. Today the gap is masked: a balanceSats` change refetches
offers and produces a new activeOffers array, and a late decimals load
triggers the quantity effect, which changes takeTokenDecimalizedQty.
Both paths are indirect. If either changes, the price and the balance
error can go stale.

Add the values the effect reads, or document why each omission is safe.


🛠️ Proposed dependency fix

-    }, [takeTokenDecimalizedQty, activeOffers, selectedIndex]);
+    }, [
+        takeTokenDecimalizedQty,
+        activeOffers,
+        selectedIndex,
+        decimals,
+        userLocale,
+        balanceSats,
+    ]);

────────────────────────────────────────────────────────────────────────

minor [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/deeplinks/index.ts:267cashtab/src/deeplinks/index.ts:267-300]8;;

The repeated-parameter check runs before the action check.

doc/standards/agora-deeplink.md states that a missing action MUST fall
back to default behavior. A link such as
https://pay.e.cash/token?price=1&price=2, or one with a repeated b and
no action, currently returns `error: 'This token action link has a
repeated parameter'. The caller in App.tsx` then shows a toast and stops
before BIP21 parsing, although the link carries no agora intent.

Resolve the action first, then apply the repeated-parameter check.


🛠️ Proposed reorder

-        const b = url.searchParams.get('b');
-
-        // A repeated b is ambiguous like any other repeated parameter — and
-        // since b itself is then untrustworthy, the error does not honor a
-        // return-to-browser request from it (empty carries false).
-        if (url.searchParams.getAll('b').length > 1) {
-            return {
-                ...empty,
-                error: 'This token action link has a repeated parameter',
-            };
-        }
-
-        // A parameter given more than once is ambiguous (which value wins?). On
-        // the dedicated agora path this is a broken agora link, so surface it
-        // rather than silently taking the first value or falling back. Error
-        // returns never honor returnToBrowser — the user should stay in the
-        // app and see what went wrong.
-        for (const key of ['action', 'tokenId', 'price', 'quantity']) {
-            if (url.searchParams.getAll(key).length > 1) {
-                return {
-                    ...empty,
-                    error: 'This token action link has a repeated parameter',
-                };
-            }
-        }
-
-        // An empty ?action= is treated as absent, like every other empty-valued
-        // parameter (the spec's empty-value rule), so it falls back rather than
-        // surfacing an unrecognized-action error
-        const action = (url.searchParams.get('action') || null)?.toUpperCase();
-        if (action === undefined) {
+        const b = url.searchParams.get('b');
+
+        // An empty ?action= is treated as absent, like every other empty-valued
+        // parameter (the spec's empty-value rule), so it falls back rather than
+        // surfacing an unrecognized-action error
+        const rawAction = url.searchParams.get('action');
+        if (rawAction === null || rawAction === '') {
             // No action at all: not an agora action link, fall back.
             return empty;
         }
+
+        // A parameter given more than once is ambiguous (which value wins?). On
+        // the dedicated agora path this is a broken agora link, so surface it
+        // rather than silently taking the first value or falling back. Error
+        // returns never honor returnToBrowser — the user should stay in the
+        // app and see what went wrong. A repeated b is ambiguous the same way,
+        // and an ambiguous b is not honored.
+        for (const key of ['action', 'tokenId', 'price', 'quantity', 'b']) {
+            if (url.searchParams.getAll(key).length > 1) {
+                return {
+                    ...empty,
+                    error: 'This token action link has a repeated parameter',
+                };
+            }
+        }
+
+        const action = rawAction.toUpperCase();

The existing test is invalid, with an error, if a parameter is repeated
keeps passing, because every case in it also carries a single valid
action.

────────────────────────────────────────
Review complete
4 findings ✔

Major 2
Minor 2

18 files reviewed:

  • cashtab/extension/public/manifest.json
  • cashtab/package.json
  • cashtab/src/components/Agora/DeepLinkBuy/__tests__/index.test.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/index.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/styled.ts
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts

... and 8 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

Fabien requested changes to this revision.Wed, Aug 12, 07:58
Fabien added inline comments.
cashtab/src/deeplinks/index.ts
272 ↗(On Diff #60845)

This can now be merged with the below check, no longer need to be special cased

This revision now requires changes to proceed.Wed, Aug 12, 07:58

Merge repeated-b check into the shared repeated-params loop

Build Bitcoin ABC Diffs / Diff Testing (ai-review) passed.
CodeRabbit Review

Diff : committed changes only
Compare : HEAD → master
Directory : work
────────────────────────────────────────

(\(\
(• .•) In Vino Veritas, In Codice Bugas. In wine, there is truth; in code, bugs.

────────────────────────────────────────────────────────────────────────

minor [Maintainability & Code Quality]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/OrderBook/index.tsx:961cashtab/src/components/Agora/OrderBook/index.tsx:961-968]8;;

Complete the truncated comment.

The sentence on Line 964 ends with "The selection is deliberately NOT" and
stops. Finish it so the intent is recorded.

────────────────────────────────────────────────────────────────────────

major [Functional Correctness]
→ ]8;;vscode://file//work/cashtab/src/components/Agora/DeepLinkBuy/index.tsx:229cashtab/src/components/Agora/DeepLinkBuy/index.tsx:229-257]8;;

Add a fallback when window.close() does not close the tab.

On web, closeOrNavigate calls window.close() and does nothing else. A
browser ignores window.close() for a tab the script did not open, for
example a link the user pasted or opened from a chat app. In that case:

- setShowSuccessModal(false) already ran, so the success modal
disappears.
- The confirm screen renders again with selectedOffer and
preparedTokenSatoshis still set, so OK is enabled and the user can take
a second offer after an already successful buy.

The comment on Lines 230-233 states that dismissToTokenPage is used
"when the tab cannot be closed (typed URL)", but no web path calls it.
Call dismissToTokenPage after the close attempt so the user lands on the
clean token page.


🐛 Proposed fix

     const closeOrNavigate = useCallback(() => {
         setShowSuccessModal(false);
         if (Capacitor.isNativePlatform()) {
             if (returnToBrowser) {
                 CapacitorApp.exitApp();
                 return;
             }
             dismissToTokenPage();
             return;
         }
         window.close();
+        // window.close() is ignored for tabs the script did not open. Fall
+        // back to the clean token page so the confirm screen cannot be
+        // re-submitted after a completed buy.
+        dismissToTokenPage();
     }, [dismissToTokenPage, returnToBrowser]);

Note that the test at Lines 396-402 and 471-474 asserts the token page is
not rendered, so it must be updated with this change.

────────────────────────────────────────
Review complete
2 findings ✔

Major 1
Minor 1

18 files reviewed:

  • cashtab/extension/public/manifest.json
  • cashtab/package.json
  • cashtab/src/components/Agora/DeepLinkBuy/__tests__/index.test.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/index.tsx
  • cashtab/src/components/Agora/DeepLinkBuy/styled.ts
  • cashtab/src/components/Agora/OrderBook/index.tsx
  • cashtab/src/components/App/App.tsx
  • cashtab/src/components/Etokens/Token/index.tsx
  • cashtab/src/components/Etokens/__tests__/TokenByUrlParams.test.js
  • cashtab/src/deeplinks/__tests__/index.test.ts

... and 8 more files
────────────────────────────────────────

Print all AI prompts: coderabbit review --show-prompts

This revision is now accepted and ready to land.Wed, Aug 12, 08:22