diff --git a/app/src/agentworld/pages/IdentitiesSection.test.tsx b/app/src/agentworld/pages/IdentitiesSection.test.tsx index b1535af6a..3e58a1c73 100644 --- a/app/src/agentworld/pages/IdentitiesSection.test.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.test.tsx @@ -1046,6 +1046,77 @@ describe('Trading tab — buy identity (x402)', () => { }); }); + test('Buy buttons re-enable after a purchase and Dismiss clears the banner (#4196)', async () => { + vi.mocked(apiClient.marketplace.buyIdentity) + .mockResolvedValueOnce({ + challenge: { amount: '20000000', asset: 'USDC', network: 'solana-devnet' }, + walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' }, + walletAddress: 'WalletXyz12345678', + }) + .mockResolvedValueOnce({ result: { saleId: 's1' }, payment: { onChainTx: 'TxId1' } }); + render(); + await gotoTab('Trading'); + await userEvent.click(await screen.findByRole('button', { name: 'Buy' })); + await userEvent.click(await screen.findByTestId('x402-confirm')); + + // Purchase completed. + await screen.findByTestId('buy-identity-success'); + + // Regression: the Buy button must NOT stay permanently disabled after a + // completed purchase (the old `disabled={buying !== null}` never reset + // because `closeBuy` was only reachable from the now-unmounted dialog). + expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled(); + + // Dismiss clears the terminal banner and keeps Buy usable. + await userEvent.click(screen.getByTestId('buy-identity-dismiss')); + expect(screen.queryByTestId('buy-identity-success')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled(); + }); + + test('a failed purchase shows the error banner and Dismiss resets it (#4196)', async () => { + vi.mocked(apiClient.marketplace.buyIdentity) + .mockResolvedValueOnce({ + challenge: { amount: '20000000', asset: 'USDC', network: 'solana-devnet' }, + walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' }, + walletAddress: 'WalletXyz12345678', + }) + .mockRejectedValueOnce(new Error('settlement boom')); + render(); + await gotoTab('Trading'); + await userEvent.click(await screen.findByRole('button', { name: 'Buy' })); + await userEvent.click(await screen.findByTestId('x402-confirm')); + + const errorBanner = await screen.findByTestId('buy-identity-error'); + expect(errorBanner).toHaveTextContent('Purchase failed.'); + + // Buy must re-enable after a failed attempt, and Dismiss clears the banner. + expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled(); + await userEvent.click(screen.getByTestId('buy-identity-dismiss')); + expect(screen.queryByTestId('buy-identity-error')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled(); + }); + + test('a post-payment failure surfaces the broadcast tx in the buy error banner (#4196)', async () => { + // The settlement broadcast went out but the purchase did not finalize: the + // error carries an `onChainTx=`, so the banner must report "Payment sent…" + // (the truthy branch of the buy error message) and re-enable Buy. + vi.mocked(apiClient.marketplace.buyIdentity) + .mockResolvedValueOnce({ + challenge: { amount: '20000000', asset: 'USDC', network: 'solana-devnet' }, + walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' }, + walletAddress: 'WalletXyz12345678', + }) + .mockRejectedValueOnce(new Error('paid but not confirmed (onChainTx=BuyTx99)')); + render(); + await gotoTab('Trading'); + await userEvent.click(await screen.findByRole('button', { name: 'Buy' })); + await userEvent.click(await screen.findByTestId('x402-confirm')); + + const errorBanner = await screen.findByTestId('buy-identity-error'); + expect(errorBanner).toHaveTextContent('Payment sent but purchase did not complete.'); + expect(screen.getByRole('button', { name: 'Buy' })).toBeEnabled(); + }); + test('auction listings do not show a Buy button', async () => { vi.mocked(apiClient.marketplace.listIdentities).mockResolvedValue({ identities: [{ ...fixedListing, listingType: 'auction' as const }], diff --git a/app/src/agentworld/pages/IdentitiesSection.tsx b/app/src/agentworld/pages/IdentitiesSection.tsx index fa5cdda8f..398e14cfb 100644 --- a/app/src/agentworld/pages/IdentitiesSection.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.tsx @@ -707,6 +707,13 @@ function TradingTab() { const [buying, setBuying] = useState(null); const buy = useX402Buy((id, opts) => apiClient.marketplace.buyIdentity(id, opts)); const bs = buy.state; + // A buy is "in flight" only while the probe/confirm/pay round-trip is active. + // Once it reaches a terminal banner (success/error) the Buy buttons must + // re-enable so the user can buy again. Otherwise `buying` stays set forever — + // `closeBuy` is only reachable from the dialog, which unmounts on success/error + // — and `disabled={buying !== null}` leaves every Buy button permanently + // greyed out after the first attempt (#4196). + const buyInFlight = buying !== null && bs.phase !== 'success' && bs.phase !== 'error'; function startBuy(listing: IdentityListing) { setBuying(listing); buy.begin(listing.listingId); @@ -810,7 +817,7 @@ function TradingTab() { variant="primary" size="xs" className="flex-1" - disabled={buying !== null} + disabled={buyInFlight} onClick={() => startBuy(listing)}> Buy @@ -876,7 +883,16 @@ function TradingTab() { - Purchased {buying.name} + + Purchased {buying.name} + + Dismiss + + {bs.onChainTx && ( - - {bs.onChainTx ? 'Payment sent but purchase did not complete.' : 'Purchase failed.'} - + + + {bs.onChainTx ? 'Payment sent but purchase did not complete.' : 'Purchase failed.'} + + + Dismiss + + {bs.message} )}
Purchased {buying.name}
- {bs.onChainTx ? 'Payment sent but purchase did not complete.' : 'Purchase failed.'} -
+ {bs.onChainTx ? 'Payment sent but purchase did not complete.' : 'Purchase failed.'} +
{bs.message}