From d0bd9100304c16cb3390e5ee6d6950394cd1255f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 13:49:58 +0000 Subject: [PATCH] fix: label Reaper sell allowance as a separate step The first wallet prompt is an ERC-20 allowance, not the sale. Spell out allow-then-sell copy and keep approve confirmation from looking like a completed sell. Co-authored-by: Rastsislau Lipski --- .../components/dashboard/ReaperActions.tsx | 45 ++++++++++++++++--- apps/web/src/components/dashboard/ui.tsx | 8 +++- apps/web/src/lib/reaperSell.test.ts | 27 +++++++++++ apps/web/src/lib/reaperSell.ts | 28 ++++++++++++ docs/nip/0011-wallet-connect.md | 7 ++- 5 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/lib/reaperSell.test.ts create mode 100644 apps/web/src/lib/reaperSell.ts diff --git a/apps/web/src/components/dashboard/ReaperActions.tsx b/apps/web/src/components/dashboard/ReaperActions.tsx index 39838b0..19cf08a 100644 --- a/apps/web/src/components/dashboard/ReaperActions.tsx +++ b/apps/web/src/components/dashboard/ReaperActions.tsx @@ -9,6 +9,13 @@ import { } from '../../lib/format.ts'; import type { NetworkConfig } from '../../lib/networks.ts'; import type { ProtocolSnapshot } from '../../lib/protocol.ts'; +import { + REAPER_ALLOWANCE_CONFIRMED, + REAPER_ALLOWANCE_PENDING, + REAPER_SALE_CONFIRMED, + REAPER_SALE_PENDING, + reaperSellStepCopy, +} from '../../lib/reaperSell.ts'; import { DEFAULT_SLIPPAGE_BPS, minOutFromQuote } from '../../lib/slippage.ts'; import { accentButtonClass } from '../wallet/ui.tsx'; import { ActionFeedback, AmountPercents, SlippageControl, TokenChip } from './ui.tsx'; @@ -64,6 +71,11 @@ export function ReaperActions({ const minEthOut = sellQuote != null ? minOutFromQuote(sellQuote, slippageBps) : 0n; const showNeth = connected && onChain && nethBalance != null; const needsApprove = nethAllowance != null && amount > 0n && nethAllowance < amount; + const justApproved = lastAction === 'approve' && !needsApprove && amount > 0n; + const sellCopy = + amount > 0n && nethAllowance != null + ? reaperSellStepCopy(needsApprove, amount, justApproved) + : null; const canSell = connected && onChain && @@ -151,10 +163,20 @@ export function ReaperActions({ )} {mobile && slippageOpen ? : null} {mobile ? null : } + {sellCopy ? ( +
+

{sellCopy.stepOne}

+

{sellCopy.stepTwo}

+
+ ) : null} - {needsApprove ? ( -

- Exact NETH allowance for this sale. Approve is a separate transaction. -

- ) : null} {mobile ? (

Waiting may improve the rate, but others can consume the budget. Unused NETH stays with @@ -181,6 +198,20 @@ export function ReaperActions({ error={reaperFeedback ? error : null} hash={reaperFeedback ? hash : null} network={network} + pendingLabel={ + pending === 'approve' + ? REAPER_ALLOWANCE_PENDING + : pending === 'sell' + ? REAPER_SALE_PENDING + : undefined + } + confirmedLabel={ + lastAction === 'approve' + ? REAPER_ALLOWANCE_CONFIRMED + : lastAction === 'sell' + ? REAPER_SALE_CONFIRMED + : undefined + } /> ); diff --git a/apps/web/src/components/dashboard/ui.tsx b/apps/web/src/components/dashboard/ui.tsx index 1973609..1171935 100644 --- a/apps/web/src/components/dashboard/ui.tsx +++ b/apps/web/src/components/dashboard/ui.tsx @@ -225,17 +225,21 @@ export function ActionFeedback({ error, hash, network, + pendingLabel, + confirmedLabel, }: { pending: string | null; error: string | null; hash: string | null; network: NetworkConfig; + pendingLabel?: string; + confirmedLabel?: string; }) { if (error) { return

{error}

; } if (pending) { - return

Confirm in wallet…

; + return

{pendingLabel ?? 'Confirm in wallet…'}

; } if (hash) { return ( @@ -246,7 +250,7 @@ export function ActionFeedback({ rel="noopener noreferrer" target="_blank" > - Transaction confirmed + {confirmedLabel ?? 'Transaction confirmed'}

diff --git a/apps/web/src/lib/reaperSell.test.ts b/apps/web/src/lib/reaperSell.test.ts new file mode 100644 index 0000000..027dd86 --- /dev/null +++ b/apps/web/src/lib/reaperSell.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + REAPER_ALLOWANCE_CONFIRMED, + formatSellNeth, + reaperSellStepCopy, +} from './reaperSell.ts'; +import { WAD } from './format.ts'; + +describe('reaperSellStepCopy', () => { + it('asks for an exact Reaper allowance before any sale', () => { + const copy = reaperSellStepCopy(true, 50n * WAD, false); + assert.equal(formatSellNeth(50n * WAD), '50 $NETH'); + assert.equal(copy.button, 'Allow Reaper to use $NETH'); + assert.match(copy.stepOne, /Allow the Reaper to use 50 \$NETH from your account/); + assert.match(copy.stepOne, /This is not the sale/); + assert.match(copy.stepTwo, /you can sell 50 \$NETH/); + }); + + it('tells the seller to confirm the sale after allowance', () => { + const copy = reaperSellStepCopy(false, 12_500n * 10n ** 14n, true); + assert.equal(copy.button, 'Sell NETH'); + assert.match(copy.stepOne, /The Reaper can now use 1\.25 \$NETH from your account/); + assert.match(copy.stepTwo, /Confirm Sell NETH to burn it and receive ETH/); + assert.equal(REAPER_ALLOWANCE_CONFIRMED, 'Allowance confirmed. This did not sell.'); + }); +}); diff --git a/apps/web/src/lib/reaperSell.ts b/apps/web/src/lib/reaperSell.ts new file mode 100644 index 0000000..8f36d8f --- /dev/null +++ b/apps/web/src/lib/reaperSell.ts @@ -0,0 +1,28 @@ +import { formatWei } from './format.ts'; + +export const REAPER_ALLOWANCE_PENDING = 'Confirm allowance in wallet…'; +export const REAPER_ALLOWANCE_CONFIRMED = 'Allowance confirmed. This did not sell.'; +export const REAPER_SALE_PENDING = 'Confirm sale in wallet…'; +export const REAPER_SALE_CONFIRMED = 'Sale confirmed'; + +export function formatSellNeth(amount: bigint): string { + return `${formatWei(amount, 4)} $NETH`; +} + +export function reaperSellStepCopy(needsApprove: boolean, amount: bigint, justApproved: boolean) { + const neth = formatSellNeth(amount); + if (needsApprove) { + return { + button: 'Allow Reaper to use $NETH', + stepOne: `1. Allow the Reaper to use ${neth} from your account. This is not the sale.`, + stepTwo: `2. After that, you can sell ${neth}.`, + }; + } + return { + button: 'Sell NETH', + stepOne: justApproved + ? `1. The Reaper can now use ${neth} from your account.` + : `1. The Reaper can use ${neth} from your account.`, + stepTwo: `2. Confirm Sell NETH to burn it and receive ETH.`, + }; +} diff --git a/docs/nip/0011-wallet-connect.md b/docs/nip/0011-wallet-connect.md index 1333d52..6cdcaed 100644 --- a/docs/nip/0011-wallet-connect.md +++ b/docs/nip/0011-wallet-connect.md @@ -293,7 +293,10 @@ Unlock **SELL NETH** when: connected, correct chain, auction active and not expi Partial fills are protocol behavior (spec §8.5): unused NETH stays with the seller. The quote already reflects remaining auction ETH. Show estimated ETH out and the spec warning that waiting may improve the rate but others can consume the budget. -Two-step approve + sell is expected. Do not hide the approve transaction. +Two-step approve + sell is expected. Do not hide the approve transaction. The Reaper pane must label the steps separately so the first wallet confirm is not mistaken for a sale: + +1. While `allowance < nethIn`, the primary control is **Allow Reaper to use $NETH**, with copy that this lets the Reaper use the exact sell amount and is not the sale. +2. After that allowance confirms, copy must say the Reaper can use that amount and the user still needs to confirm **Sell NETH**. Do not show a generic “Transaction confirmed” after approve as if the sale completed. ### 7.5 Start auction (and finalize) @@ -396,7 +399,7 @@ This slice is done when: - Other EIP-6963 wallets appear without a code change; **Other wallet** is the catch-all when a project ID is configured - ETH and $NETH balances show for the connected account on the correct chain ($NETH on the account chip and $NETH bar; ETH optional on the chip) - **BURY ETH** sends `bury(minNethOut)` with the spec warning visible before the wallet prompt -- **SELL NETH** approves exact NETH then `sellToReaper`; inactive/expired auctions cannot sell +- **SELL NETH** is two labeled steps (allow Reaper to use exact $NETH, then sell); inactive/expired auctions cannot sell - When idle Reaper ETH exists, a connected user can **start** an auction; when an auction is expired they can **finalize** first - On Base Sepolia, the account menu offers **Add Base Sepolia** when the chain is not already selected / added on this origin; wallets that implement EIP-3085 get a native prompt; others get RPC URLs, chain id `84532` / `0x14a34`, explorer, and currency - The account menu offers **Add $NETH** for the selected network’s token address until the user has pressed it on this origin; native `wallet_watchAsset` (including `image: nethMarkUrl()`) on supported wallets; manual contract / symbol / decimals guide otherwise