From fba88bbf9c33b52ea969c9e262d2303bd7af3ba9 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 11 May 2026 18:27:04 -0500 Subject: [PATCH 1/2] feat: browser swap page wired to swap-api (PR #2 of browser-swap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the blurred /swap teaser with a working swap form, wallet connect, state-machine-driven reserve/send/confirm flow, progress UI, browser-side claim button, and a My Swaps tab. Consumes the swap-api FastAPI service introduced in allways#311. - WalletProvider with Polkadot.js (full) and Unisat (full); Xverse/Leather surface as detection-only stubs until v2. - useSwapFlow state machine: idle → awaitingReserveSig → reserving → awaitingSend → sending → awaitingConfirmSig → confirming → watching → done. RateChangedDialog handles 409 responses with re-accept. - usePendingSwap persists intent + signatures + swap id in localStorage keyed by (fromAddress, miner, blockAnchor) so a refresh doesn't lose context. Cross-tab BroadcastChannel arbitration deferred to v2. - ClaimSlashedButton signs the claim_slash extrinsic directly via @polkadot/api when the connected Substrate wallet matches the swap user; falls back to a CLI hint otherwise. - Adds VITE_SWAP_API_URL (default http://localhost:8000) and VITE_SUBTENSOR_WS_URL to .env.example. Refs spec §4–§7 and roadmap PR #2. --- .env.example | 10 +- package-lock.json | 947 +++++++++++++++++++++ package.json | 4 + src/App.tsx | 17 +- src/api/SwapApi.ts | 128 +++ src/api/SwapApiClient.ts | 107 +++ src/api/index.ts | 2 + src/components/nav/TopNav.tsx | 10 +- src/components/swap/ClaimSlashedButton.tsx | 89 ++ src/components/swap/RateChangedDialog.tsx | 89 ++ src/components/swap/SwapDetails.tsx | 90 ++ src/components/swap/SwapForm.tsx | 303 +++++++ src/components/swap/SwapProgress.tsx | 135 +++ src/components/swap/TokenInput.tsx | 69 +- src/components/swap/index.ts | 6 + src/env.d.ts | 3 + src/hooks/index.ts | 4 + src/hooks/usePendingSwap.ts | 98 +++ src/hooks/useSwapFlow.ts | 270 ++++++ src/pages/MySwapsPage.tsx | 118 +++ src/pages/SwapDetailPage.tsx | 14 + src/pages/SwapPage.tsx | 289 +++---- src/pages/index.ts | 1 + src/routes.tsx | 2 + src/wallet/ConnectWalletDialog.tsx | 221 +++++ src/wallet/SubstrateOptionalBanner.tsx | 57 ++ src/wallet/WalletProvider.tsx | 86 ++ src/wallet/bitcoin.ts | 96 +++ src/wallet/index.ts | 6 + src/wallet/substrate.ts | 147 ++++ 30 files changed, 3225 insertions(+), 193 deletions(-) create mode 100644 src/api/SwapApi.ts create mode 100644 src/api/SwapApiClient.ts create mode 100644 src/components/swap/ClaimSlashedButton.tsx create mode 100644 src/components/swap/RateChangedDialog.tsx create mode 100644 src/components/swap/SwapDetails.tsx create mode 100644 src/components/swap/SwapForm.tsx create mode 100644 src/components/swap/SwapProgress.tsx create mode 100644 src/hooks/usePendingSwap.ts create mode 100644 src/hooks/useSwapFlow.ts create mode 100644 src/pages/MySwapsPage.tsx create mode 100644 src/wallet/ConnectWalletDialog.tsx create mode 100644 src/wallet/SubstrateOptionalBanner.tsx create mode 100644 src/wallet/WalletProvider.tsx create mode 100644 src/wallet/bitcoin.ts create mode 100644 src/wallet/index.ts create mode 100644 src/wallet/substrate.ts diff --git a/.env.example b/.env.example index 7062504..2b927fc 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,10 @@ -# API base URL +# das-allways read API base URL (swaps, reservations, miners, sse) VITE_REACT_APP_BASE_URL=http://localhost:9081 + +# swap-api base URL — wraps the alw CLI swap flow over HTTP (spec §6). +# In dev, the FastAPI app from `allways/swap_api/` runs on :8000. +VITE_SWAP_API_URL=http://localhost:8000 + +# Optional: WS endpoint for direct subtensor calls (used by the in-browser +# claim-slash flow). Defaults to the local dev subtensor. +VITE_SUBTENSOR_WS_URL=ws://localhost:9944 diff --git a/package-lock.json b/package-lock.json index ebece7c..7fff7fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,10 @@ "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.16.4", "@mui/material": "^5.16.1", + "@polkadot/api": "^16.5.6", + "@polkadot/extension-dapp": "^0.63.1", + "@polkadot/types": "^16.5.6", + "@polkadot/util-crypto": "^14.0.3", "@tanstack/react-query": "^5.51.15", "axios": "^1.7.2", "react": "^18.3.1", @@ -1143,6 +1147,33 @@ } } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1181,6 +1212,629 @@ "node": ">= 8" } }, + "node_modules/@polkadot-api/json-rpc-provider": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider/-/json-rpc-provider-0.0.1.tgz", + "integrity": "sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/json-rpc-provider-proxy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.1.0.tgz", + "integrity": "sha512-8GSFE5+EF73MCuLQm8tjrbCqlgclcHBSRaswvXziJ0ZW7iw3UEMsKkkKvELayWyBuOPa2T5i1nj6gFOeIsqvrg==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot-api/metadata-builders": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/metadata-builders/-/metadata-builders-0.3.2.tgz", + "integrity": "sha512-TKpfoT6vTb+513KDzMBTfCb/ORdgRnsS3TDFpOhAhZ08ikvK+hjHMt5plPiAX/OWkm1Wc9I3+K6W0hX5Ab7MVg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/substrate-bindings": "0.6.0", + "@polkadot-api/utils": "0.1.0" + } + }, + "node_modules/@polkadot-api/observable-client": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.3.2.tgz", + "integrity": "sha512-HGgqWgEutVyOBXoGOPp4+IAq6CNdK/3MfQJmhCJb8YaJiaK4W6aRGrdQuQSTPHfERHCARt9BrOmEvTXAT257Ug==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/metadata-builders": "0.3.2", + "@polkadot-api/substrate-bindings": "0.6.0", + "@polkadot-api/utils": "0.1.0" + }, + "peerDependencies": { + "@polkadot-api/substrate-client": "0.1.4", + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/substrate-bindings": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.6.0.tgz", + "integrity": "sha512-lGuhE74NA1/PqdN7fKFdE5C1gNYX357j1tWzdlPXI0kQ7h3kN0zfxNOpPUN7dIrPcOFZ6C0tRRVrBylXkI6xPw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "^1.3.1", + "@polkadot-api/utils": "0.1.0", + "@scure/base": "^1.1.1", + "scale-ts": "^1.6.0" + } + }, + "node_modules/@polkadot-api/substrate-client": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.1.4.tgz", + "integrity": "sha512-MljrPobN0ZWTpn++da9vOvt+Ex+NlqTlr/XT7zi9sqPtDJiQcYl+d29hFAgpaeTqbeQKZwz3WDE9xcEfLE8c5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.0.1", + "@polkadot-api/utils": "0.1.0" + } + }, + "node_modules/@polkadot-api/utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/utils/-/utils-0.1.0.tgz", + "integrity": "sha512-MXzWZeuGxKizPx2Xf/47wx9sr/uxKw39bVJUptTJdsaQn/TGq+z310mHzf1RCGvC1diHM8f593KrnDgc9oNbJA==", + "license": "MIT", + "optional": true + }, + "node_modules/@polkadot/api": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/api/-/api-16.5.6.tgz", + "integrity": "sha512-5h/X3pY8WpqGk4XTaiIUjKD6Pnk8k4bJ6EIwPKLP8/kfFWKSOenpN6ggZxANr+Qj+RgXrp4TxJVcuhXSiBh9Sg==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api-augment": "16.5.6", + "@polkadot/api-base": "16.5.6", + "@polkadot/api-derive": "16.5.6", + "@polkadot/keyring": "^14.0.3", + "@polkadot/rpc-augment": "16.5.6", + "@polkadot/rpc-core": "16.5.6", + "@polkadot/rpc-provider": "16.5.6", + "@polkadot/types": "16.5.6", + "@polkadot/types-augment": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/types-create": "16.5.6", + "@polkadot/types-known": "16.5.6", + "@polkadot/util": "^14.0.3", + "@polkadot/util-crypto": "^14.0.3", + "eventemitter3": "^5.0.1", + "rxjs": "^7.8.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-augment": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/api-augment/-/api-augment-16.5.6.tgz", + "integrity": "sha512-bunJF1c3nIuDtU6iwa+reTt9U47Y8iOC8Gw7PfANlZmLJmO/XVXnWc3JJLM+g9ESDn2raHJELeWBFVOXQrbtUw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api-base": "16.5.6", + "@polkadot/rpc-augment": "16.5.6", + "@polkadot/types": "16.5.6", + "@polkadot/types-augment": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/util": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-base": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/api-base/-/api-base-16.5.6.tgz", + "integrity": "sha512-eBLIv86ZZY4t5OrobVoGC+QXbErOGlBpI2rJI5OMvTNPoVvtEoI++u+wwRScjkOZaUhXyQikd+0Uv71qr3xnsA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-core": "16.5.6", + "@polkadot/types": "16.5.6", + "@polkadot/util": "^14.0.3", + "rxjs": "^7.8.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/api-derive": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/api-derive/-/api-derive-16.5.6.tgz", + "integrity": "sha512-cHdvPvhYFch18uPTcuOZJ8VceOfercod2fi4xCnHJAmattzlgj9qCgnOoxdmBS9GZ403ZyRHOjBuUwZy/IsUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api": "16.5.6", + "@polkadot/api-augment": "16.5.6", + "@polkadot/api-base": "16.5.6", + "@polkadot/rpc-core": "16.5.6", + "@polkadot/types": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/util": "^14.0.3", + "@polkadot/util-crypto": "^14.0.3", + "rxjs": "^7.8.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/extension-dapp": { + "version": "0.63.1", + "resolved": "https://registry.npmjs.org/@polkadot/extension-dapp/-/extension-dapp-0.63.1.tgz", + "integrity": "sha512-/HHvyVOg7Z8LNtzS4yt2CUSp3wLRayW3eG5lhUDXVQrNlzIY6O7XwyRABDIIBfEOJ6UEknxZBz0ieeu5cFJfeQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/extension-inject": "0.63.1", + "@polkadot/util": "^14.0.3", + "@polkadot/util-crypto": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/api": "*", + "@polkadot/util": "*", + "@polkadot/util-crypto": "*" + } + }, + "node_modules/@polkadot/extension-inject": { + "version": "0.63.1", + "resolved": "https://registry.npmjs.org/@polkadot/extension-inject/-/extension-inject-0.63.1.tgz", + "integrity": "sha512-C8xOP9ixgNnvjEDYFxGVCFPBlGX7nXNYjeDK1WH1bRvnh6FCv5J4IMS3MvMadQErYrrhcqz1zQy8MR3iLQZpEg==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/api": "^16.5.6", + "@polkadot/rpc-provider": "^16.5.6", + "@polkadot/types": "^16.5.6", + "@polkadot/util": "^14.0.3", + "@polkadot/util-crypto": "^14.0.3", + "@polkadot/x-global": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/api": "*", + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/keyring": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/keyring/-/keyring-14.0.3.tgz", + "integrity": "sha512-ozp1dQwaHCjgX/fpTTORmHjxdUNQnyiTVJszpzUaUpvtH/IGZhSU/mSHXMqNETS/g57vQa7NatIDcWfyR9abyA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "14.0.3", + "@polkadot/util-crypto": "14.0.3", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "14.0.3", + "@polkadot/util-crypto": "14.0.3" + } + }, + "node_modules/@polkadot/networks": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/networks/-/networks-14.0.3.tgz", + "integrity": "sha512-/VqTLUDn+Wm8S2L/yaGFddo3oW4vRYav0Rg4pLg/semMZLaN8PJ6h927ucn9JyWdH82QfZfyiIPORt0ZF3isyw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "14.0.3", + "@substrate/ss58-registry": "^1.51.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-augment": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-augment/-/rpc-augment-16.5.6.tgz", + "integrity": "sha512-vlrNvl2VtU09jZV/AvH7jBb/cNUO+dWu8Xj9pId5ctSUnZHm8o8wRk9ekyieKP57OUoKMd8+VScwMKd624SxTw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-core": "16.5.6", + "@polkadot/types": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/util": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-core": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-core/-/rpc-core-16.5.6.tgz", + "integrity": "sha512-l6od++WlvKH4mw5mtsIh2AhiBs3H+TtdOoUHVLCx/R9il7+gl+arltzZ8vBuffyh/O+uQ36lI8yUoD1g4gi1tA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/rpc-augment": "16.5.6", + "@polkadot/rpc-provider": "16.5.6", + "@polkadot/types": "16.5.6", + "@polkadot/util": "^14.0.3", + "rxjs": "^7.8.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/rpc-provider": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/rpc-provider/-/rpc-provider-16.5.6.tgz", + "integrity": "sha512-46sHIjKYr4aSzBCfbyqtCwuP8MMJ3jOp0xx9eggOGbKyP8Z0j0Cp+1nNkZUYzehcdGjjrmCxCbQp17wc6cj4zA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/keyring": "^14.0.3", + "@polkadot/types": "16.5.6", + "@polkadot/types-support": "16.5.6", + "@polkadot/util": "^14.0.3", + "@polkadot/util-crypto": "^14.0.3", + "@polkadot/x-fetch": "^14.0.3", + "@polkadot/x-global": "^14.0.3", + "@polkadot/x-ws": "^14.0.3", + "eventemitter3": "^5.0.1", + "mock-socket": "^9.3.1", + "nock": "^13.5.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@substrate/connect": "0.8.11" + } + }, + "node_modules/@polkadot/types": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/types/-/types-16.5.6.tgz", + "integrity": "sha512-X/sfMHJS4RkRhnsc4CQqzUy7BM/s2y71TrBFHPYAjs2q/rbZ/BwvBk70SrUiSa0+iRRn3RewbBZm+AB8CbkdKw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/keyring": "^14.0.3", + "@polkadot/types-augment": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/types-create": "16.5.6", + "@polkadot/util": "^14.0.3", + "@polkadot/util-crypto": "^14.0.3", + "rxjs": "^7.8.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-augment": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/types-augment/-/types-augment-16.5.6.tgz", + "integrity": "sha512-QN5UrluUZCVgknUDW0gps/FRQ13Qgm24w53pCd2HgD0nmTtXDt9D4psjWwx5JkGTkUAvpzFWwN41bkxAeCiV6g==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/types": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/util": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-codec": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/types-codec/-/types-codec-16.5.6.tgz", + "integrity": "sha512-3tzUv1LZOL97IlQmko4dqbfRC0cg9IQ2QAHRVoDIWsXrVovp1V3kPdP0o6e3I8T2XB9IlbabK91v+ZiIxhGMZw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "^14.0.3", + "@polkadot/x-bigint": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-create": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/types-create/-/types-create-16.5.6.tgz", + "integrity": "sha512-g7g3hrjpz4KgqQqei9PU0JY9fsFHBmThWALZk5pWB32vyDyDcXZiyhH3agDhqfmzQiolTW2FuvcNJxgS634J1w==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/types-codec": "16.5.6", + "@polkadot/util": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-known": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/types-known/-/types-known-16.5.6.tgz", + "integrity": "sha512-c78NcVO3LIvi4xzxB39WewE+80I4jOYUtPBaB4AzSMespEwIr92VTeX3KzFWuutxDXLSPqeVfXhaAhBB0NssiQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/networks": "^14.0.3", + "@polkadot/types": "16.5.6", + "@polkadot/types-codec": "16.5.6", + "@polkadot/types-create": "16.5.6", + "@polkadot/util": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/types-support": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/@polkadot/types-support/-/types-support-16.5.6.tgz", + "integrity": "sha512-Hqpa/hCvXZXUTUiJMAE55UXpzAeCVLaFlzzXQXLkne0vhmv3/JkWcBnX755a/b9+C4b3MKEz2i0tSKLsa3DldA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/util": "^14.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/util": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/util/-/util-14.0.3.tgz", + "integrity": "sha512-mg1NR7ixHlNiz2zbvdcdy1OXZmca2tVA4DpewGpY/qFkW/gq9HdDrHLu7g0k90QnunDcFW4emb7NB60sGJQ0bw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-bigint": "14.0.3", + "@polkadot/x-global": "14.0.3", + "@polkadot/x-textdecoder": "14.0.3", + "@polkadot/x-textencoder": "14.0.3", + "@types/bn.js": "^5.1.6", + "bn.js": "^5.2.1", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/util-crypto": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-14.0.3.tgz", + "integrity": "sha512-V00BI6XnZLCkrAmV8uN0eSB6fy48CkxdDZT29cgSMSwHPtY6oKUNgd1ST07PGCL5x8XflwjoA7CTlhdbp1Y9gw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.3.3", + "@polkadot/networks": "14.0.3", + "@polkadot/util": "14.0.3", + "@polkadot/wasm-crypto": "^7.5.3", + "@polkadot/wasm-util": "^7.5.3", + "@polkadot/x-bigint": "14.0.3", + "@polkadot/x-randomvalues": "14.0.3", + "@scure/base": "^1.1.7", + "@scure/sr25519": "^0.2.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "14.0.3" + } + }, + "node_modules/@polkadot/wasm-bridge": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-bridge/-/wasm-bridge-7.5.4.tgz", + "integrity": "sha512-6xaJVvoZbnbgpQYXNw9OHVNWjXmtcoPcWh7hlwx3NpfiLkkjljj99YS+XGZQlq7ks2fVCg7FbfknkNb8PldDaA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto/-/wasm-crypto-7.5.4.tgz", + "integrity": "sha512-1seyClxa7Jd7kQjfnCzTTTfYhTa/KUTDUaD3DMHBk5Q4ZUN1D1unJgX+v1aUeXSPxmzocdZETPJJRZjhVOqg9g==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-bridge": "7.5.4", + "@polkadot/wasm-crypto-asmjs": "7.5.4", + "@polkadot/wasm-crypto-init": "7.5.4", + "@polkadot/wasm-crypto-wasm": "7.5.4", + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-asmjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-asmjs/-/wasm-crypto-asmjs-7.5.4.tgz", + "integrity": "sha512-ZYwxQHAJ8pPt6kYk9XFmyuFuSS+yirJLonvP+DYbxOrARRUHfN4nzp4zcZNXUuaFhpbDobDSFn6gYzye6BUotA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-init": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-init/-/wasm-crypto-init-7.5.4.tgz", + "integrity": "sha512-U6s4Eo2rHs2n1iR01vTz/sOQ7eOnRPjaCsGWhPV+ZC/20hkVzwPAhiizu/IqMEol4tO2yiSheD4D6bn0KxUJhg==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-bridge": "7.5.4", + "@polkadot/wasm-crypto-asmjs": "7.5.4", + "@polkadot/wasm-crypto-wasm": "7.5.4", + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*", + "@polkadot/x-randomvalues": "*" + } + }, + "node_modules/@polkadot/wasm-crypto-wasm": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-wasm/-/wasm-crypto-wasm-7.5.4.tgz", + "integrity": "sha512-PsHgLsVTu43eprwSvUGnxybtOEuHPES6AbApcs7y5ZbM2PiDMzYbAjNul098xJK/CPtrxZ0ePDFnaQBmIJyTFw==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/wasm-util": "7.5.4", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/wasm-util": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@polkadot/wasm-util/-/wasm-util-7.5.4.tgz", + "integrity": "sha512-hqPpfhCpRAqCIn/CYbBluhh0TXmwkJnDRjxrU9Bnqtw9nMNa97D8JuOjdd2pi0rxm+eeLQ/f1rQMp71RMM9t4w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "*" + } + }, + "node_modules/@polkadot/x-bigint": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-bigint/-/x-bigint-14.0.3.tgz", + "integrity": "sha512-U0al6BKgldFrEbmSObRAlzv9VDs5SMa/rbvZKvvkVec0sWTzYPWQZU1ZC/biXLYdjdKML89BeuCKmXZtCcGhUQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "14.0.3", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-fetch": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-fetch/-/x-fetch-14.0.3.tgz", + "integrity": "sha512-695c5aPBPtYcnn2zM+u0mXgyNHINlO0qGlGcJq3/0t5NVRZv5KZhk7NNm6antOay9uUjGG40F/r+LPzDT3QamA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "14.0.3", + "node-fetch": "^3.3.2", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-global": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-global/-/x-global-14.0.3.tgz", + "integrity": "sha512-MzMEynJ7HMTy/plLmdyP8rv14RS/6s29HZodUG9aCOscBnEiEDxVEax/ztRJqxhhQuHeYdx0LYDwVbdQDTkqNw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-randomvalues": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-14.0.3.tgz", + "integrity": "sha512-qTPcrk0nIHL2tIu5e0cLj3puQvjCK7onehnqO2fvlmWeIlvDel66fwWs06Ipsib+CwLJdmE6WgNy+8Jv74r6YA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "14.0.3", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@polkadot/util": "14.0.3", + "@polkadot/wasm-util": "*" + } + }, + "node_modules/@polkadot/x-textdecoder": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-textdecoder/-/x-textdecoder-14.0.3.tgz", + "integrity": "sha512-4RJYDG00iUzQ7YAuS/yvkWRZlkjYU8PUNdJHRfqtJ+SjrSPB7LYYxFhLgw43TZUtHmIueNTsml2Ukv3xXTr2kA==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "14.0.3", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-textencoder": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-textencoder/-/x-textencoder-14.0.3.tgz", + "integrity": "sha512-9HH6o2L+r99wEfXhPb5g+Xwn7qouqD32PsMux7B0dFGR2KNqP4KwO19Hu+gdij6wsEhy7delhZwzHenrWwDfhQ==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "14.0.3", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polkadot/x-ws": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@polkadot/x-ws/-/x-ws-14.0.3.tgz", + "integrity": "sha512-tOPdkMye3iuXnuFtdNg5+iSu7Cz9LRL8z5psMuZpUpThMYChGsS2pDFtNvXOKU8ohhO+frY9VdJ9VBg1WL9Iug==", + "license": "Apache-2.0", + "dependencies": { + "@polkadot/x-global": "14.0.3", + "tslib": "^2.8.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1557,6 +2211,81 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/sr25519": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@scure/sr25519/-/sr25519-0.2.0.tgz", + "integrity": "sha512-uUuLP7Z126XdSizKtrCGqYyR3b3hYtJ6Fg/XFUXmc2//k2aXHDLqZwFeXxL97gg4XydPROPVnuaHGF2+xriSKg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.2", + "@noble/hashes": "~1.8.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@substrate/connect": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@substrate/connect/-/connect-0.8.11.tgz", + "integrity": "sha512-ofLs1PAO9AtDdPbdyTYj217Pe+lBfTLltdHDs3ds8no0BseoLeAGxpz1mHfi7zB4IxI3YyAiLjH6U8cw4pj4Nw==", + "deprecated": "versions below 1.x are no longer maintained", + "license": "GPL-3.0-only", + "optional": true, + "dependencies": { + "@substrate/connect-extension-protocol": "^2.0.0", + "@substrate/connect-known-chains": "^1.1.5", + "@substrate/light-client-extension-helpers": "^1.0.0", + "smoldot": "2.0.26" + } + }, + "node_modules/@substrate/connect-extension-protocol": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@substrate/connect-extension-protocol/-/connect-extension-protocol-2.2.2.tgz", + "integrity": "sha512-t66jwrXA0s5Goq82ZtjagLNd7DPGCNjHeehRlE/gcJmJ+G56C0W+2plqOMRicJ8XGR1/YFnUSEqUFiSNbjGrAA==", + "license": "GPL-3.0-only", + "optional": true + }, + "node_modules/@substrate/connect-known-chains": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@substrate/connect-known-chains/-/connect-known-chains-1.10.3.tgz", + "integrity": "sha512-OJEZO1Pagtb6bNE3wCikc2wrmvEU5x7GxFFLqqbz1AJYYxSlrPCGu4N2og5YTExo4IcloNMQYFRkBGue0BKZ4w==", + "license": "GPL-3.0-only", + "optional": true + }, + "node_modules/@substrate/light-client-extension-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@substrate/light-client-extension-helpers/-/light-client-extension-helpers-1.0.0.tgz", + "integrity": "sha512-TdKlni1mBBZptOaeVrKnusMg/UBpWUORNDv5fdCaJklP4RJiFOzBCrzC+CyVI5kQzsXBisZ+2pXm+rIjS38kHg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@polkadot-api/json-rpc-provider": "^0.0.1", + "@polkadot-api/json-rpc-provider-proxy": "^0.1.0", + "@polkadot-api/observable-client": "^0.3.0", + "@polkadot-api/substrate-client": "^0.1.2", + "@substrate/connect-extension-protocol": "^2.0.0", + "@substrate/connect-known-chains": "^1.1.5", + "rxjs": "^7.8.1" + }, + "peerDependencies": { + "smoldot": "2.x" + } + }, + "node_modules/@substrate/ss58-registry": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@substrate/ss58-registry/-/ss58-registry-1.51.0.tgz", + "integrity": "sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/core": { "version": "1.15.18", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", @@ -1809,6 +2538,15 @@ "react": "^18 || ^19" } }, + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1816,6 +2554,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.21.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -2226,6 +2973,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", @@ -2369,6 +3122,15 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2775,6 +3537,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2824,6 +3592,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2918,6 +3709,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3334,6 +4137,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3445,6 +4254,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3477,6 +4295,58 @@ "dev": true, "license": "MIT" }, + "node_modules/nock": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", + "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3712,6 +4582,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3974,6 +4853,22 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scale-ts": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/scale-ts/-/scale-ts-1.6.1.tgz", + "integrity": "sha512-PBMc2AWc6wSEqJYBDPcyCLUj9/tMKnLX70jLOSndMtcUoLQucP/DM0vnQo1wJAYjTrQiq8iG9rD0q6wFzgjH7g==", + "license": "MIT", + "optional": true + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4025,6 +4920,16 @@ "node": ">=8" } }, + "node_modules/smoldot": { + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-2.0.26.tgz", + "integrity": "sha512-F+qYmH4z2s2FK+CxGj8moYcd1ekSIKH8ywkdqlOz88Dat35iB1DIYL11aILN46YSGMzQW/lbJNS307zBSDN5Ig==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", + "optional": true, + "dependencies": { + "ws": "^8.8.1" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -4138,6 +5043,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4178,6 +5089,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4248,6 +5165,15 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4281,6 +5207,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", diff --git a/package.json b/package.json index 3121a66..8ead6f8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.16.4", "@mui/material": "^5.16.1", + "@polkadot/api": "^16.5.6", + "@polkadot/extension-dapp": "^0.63.1", + "@polkadot/types": "^16.5.6", + "@polkadot/util-crypto": "^14.0.3", "@tanstack/react-query": "^5.51.15", "axios": "^1.7.2", "react": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index ea5949a..4c710fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,15 +2,18 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { AppLayout } from './components/layout'; import routes from './routes'; +import { WalletProvider } from './wallet/WalletProvider'; const App: React.FC = () => ( - - }> - {Object.values(routes).map((x) => ( - - ))} - - + + + }> + {Object.values(routes).map((x) => ( + + ))} + + + ); export default App; diff --git a/src/api/SwapApi.ts b/src/api/SwapApi.ts new file mode 100644 index 0000000..a06dba3 --- /dev/null +++ b/src/api/SwapApi.ts @@ -0,0 +1,128 @@ +/** + * React Query hooks over swap-api endpoints. + * + * Conventions: + * - Reads use `useQuery` with stable keys so they de-dupe across components. + * - Writes use `useMutation` so callers can drive the swap state machine. + */ + +import { + useMutation, + useQuery, + type UseMutationResult, + type UseQueryResult, +} from '@tanstack/react-query'; +import type { AxiosError } from 'axios'; +import { + swapApiClient, + type BestMinerResponse, + type ChainsResponse, + type ConfirmRequest, + type ConfirmResponse, + type HealthResponse, + type MinerSummary, + type ProofMessage, + type RateChangedError, + type ReserveRequest, + type ReserveResponse, +} from './SwapApiClient'; + +export const useSwapApiHealth = (): UseQueryResult => + useQuery({ + queryKey: ['swapApi', 'health'], + queryFn: async () => + (await swapApiClient.get('/healthz')).data, + refetchInterval: 30000, + retry: false, + }); + +export const useChains = (): UseQueryResult => + useQuery({ + queryKey: ['swapApi', 'chains'], + queryFn: async () => + (await swapApiClient.get('/chains')).data, + staleTime: 60 * 60 * 1000, + }); + +export const useMinersForPair = ( + from: string, + to: string, + enabled = true, +): UseQueryResult => + useQuery({ + queryKey: ['swapApi', 'miners', from, to], + queryFn: async () => + ( + await swapApiClient.get('/miners', { + params: { from, to }, + }) + ).data, + enabled: enabled && !!from && !!to && from !== to, + refetchInterval: 15000, + }); + +export const useBestMiner = ( + from: string, + to: string, + amount: number, + enabled = true, +): UseQueryResult => + useQuery({ + queryKey: ['swapApi', 'best', from, to, amount], + queryFn: async () => + ( + await swapApiClient.get('/miners/best', { + params: { from, to, amount }, + }) + ).data, + enabled: enabled && !!from && !!to && from !== to && amount > 0, + refetchInterval: 15000, + retry: false, + }); + +export const fetchReserveProof = async ( + address: string, + block: number, +): Promise => + ( + await swapApiClient.get('/proofs/reserve', { + params: { address, block }, + }) + ).data; + +export const fetchConfirmProof = async ( + txHash: string, +): Promise => + ( + await swapApiClient.get('/proofs/confirm', { + params: { txHash }, + }) + ).data; + +export const useReserveMutation = (): UseMutationResult< + ReserveResponse, + AxiosError, + ReserveRequest +> => + useMutation({ + mutationFn: async (req: ReserveRequest) => + (await swapApiClient.post('/reserve', req)).data, + }); + +export const useConfirmMutation = (): UseMutationResult< + ConfirmResponse, + AxiosError<{ detail: string }>, + ConfirmRequest +> => + useMutation({ + mutationFn: async (req: ConfirmRequest) => + (await swapApiClient.post('/confirm', req)).data, + }); + +export const isRateChangedError = ( + err: AxiosError | null | undefined, +): err is AxiosError => { + if (!err || err.response?.status !== 409) return false; + const data = err.response?.data as Partial | undefined; + return data?.code === 'RateChanged'; +}; diff --git a/src/api/SwapApiClient.ts b/src/api/SwapApiClient.ts new file mode 100644 index 0000000..fdbb707 --- /dev/null +++ b/src/api/SwapApiClient.ts @@ -0,0 +1,107 @@ +/** + * Axios client + types for the swap-api FastAPI service (spec §6). + * + * Distinct from the das-allways read API (`ApiUtils.ts`) — swap-api is the + * stateless mutating surface that wraps the CLI flow. + */ + +import axios, { type AxiosInstance } from 'axios'; + +export const SWAP_API_BASE_URL = + (import.meta.env.VITE_SWAP_API_URL as string | undefined) ?? + 'http://localhost:8000'; + +export const swapApiClient: AxiosInstance = axios.create({ + baseURL: SWAP_API_BASE_URL, + timeout: 30000, +}); + +/* ---------- Types — mirror Pydantic shapes in allways/swap_api/models.py ---------- */ + +export interface HealthResponse { + ok: boolean; + chainBlock: number | null; + contractAddress: string; +} + +export interface ChainInfo { + id: string; + name: string; + decimals: number; + native_unit: string; +} + +export interface ChainsResponse { + chains: ChainInfo[]; + pairs: Array<[string, string]>; +} + +export interface MinerSummary { + hotkey: string; + rate: string; + collateralRao: number; + isActive: boolean; + hasActiveSwap: boolean; +} + +export interface BestMinerResponse { + minerHotkey: string; + rate: string; + expectedOut: number; + reservationCapacity: number; + sourceAddress: string; + freshAsOf: number; +} + +export interface ProofMessage { + message: string; +} + +export interface ReserveRequest { + minerHotkey: string; + fromChain: string; + toChain: string; + taoAmount: number; + fromAmount: number; + toAmount: number; + fromAddress: string; + fromAddressProof: string; + blockAnchor: number; + expectedRate: string; +} + +export interface ReserveResponse { + requestHash: string; + reservedUntilBlock: number; + minerSourceAddress: string; + minerHotkey: string; +} + +export interface ConfirmRequest { + requestHash: string; + /** + * PR #1 spec deviation: confirm body MUST include minerHotkey. + * The browser already receives it in the ReserveResponse, so just thread it + * through. + */ + minerHotkey: string; + fromTxHash: string; + fromTxProof: string; + fromAddress: string; + toAddress: string; + fromChain: string; + toChain: string; + fromTxBlock: number; +} + +export interface ConfirmResponse { + accepted: boolean; + swapId?: number; + rejection?: string; +} + +export interface RateChangedError { + code: 'RateChanged'; + expected: string; + actual: string; +} diff --git a/src/api/index.ts b/src/api/index.ts index 236c127..b1f39d3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -5,5 +5,7 @@ export * from './ProtocolApi'; export * from './ReservationsApi'; export * from './StatsApi'; export * from './SwapsApi'; +export * from './SwapApiClient'; +export * from './SwapApi'; export * from './models'; diff --git a/src/components/nav/TopNav.tsx b/src/components/nav/TopNav.tsx index 0dd43b8..61143b1 100644 --- a/src/components/nav/TopNav.tsx +++ b/src/components/nav/TopNav.tsx @@ -20,6 +20,7 @@ import { useThemeMode } from '../../ThemeContext'; import BrandMark from '../BrandMark'; import SocialLinks from './SocialLinks'; import { NAV_ITEMS, docsUrl } from './links'; +import { useWallet } from '../../wallet/WalletProvider'; const navBtnSx = (active: boolean) => ({ fontFamily: FONTS.mono, @@ -59,6 +60,11 @@ const TopNav: React.FC = () => { const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [menuAnchor, setMenuAnchor] = useState(null); const docs = docsUrl(); + const { substrate, bitcoin } = useWallet(); + const walletConnected = !!substrate || !!bitcoin; + const navItems = walletConnected + ? [...NAV_ITEMS, { label: 'My Swaps', to: '/my-swaps' }] + : NAV_ITEMS; const isActive = (to: string): boolean => { if (to === '/dashboard') { @@ -114,7 +120,7 @@ const TopNav: React.FC = () => { {!isMobile && ( - {NAV_ITEMS.map((item) => ( + {navItems.map((item) => ( { }} MenuListProps={{ sx: { py: 0 } }} > - {NAV_ITEMS.map((item) => ( + {navItems.map((item) => ( = ({ + swapId, + expectedUserAddress, +}) => { + const { substrate } = useWallet(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [txRef, setTxRef] = useState(null); + + if (!substrate) { + return ( + + + Connect a Substrate wallet to claim from the browser, or run{' '} + alw claim {swapId} from the CLI. + + + ); + } + + if ( + expectedUserAddress && + substrate.address.toLowerCase() !== expectedUserAddress.toLowerCase() + ) { + return null; + } + + const handleClaim = async () => { + setBusy(true); + setError(null); + try { + const ref = await claimSlash(substrate, swapId); + setTxRef(ref); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( + + + {txRef && ( + + Claim submitted ({txRef}). Refresh after a few blocks to see funds. + + )} + {error && ( + + {error} + + )} + + ); +}; + +export default ClaimSlashedButton; diff --git a/src/components/swap/RateChangedDialog.tsx b/src/components/swap/RateChangedDialog.tsx new file mode 100644 index 0000000..fd60a9d --- /dev/null +++ b/src/components/swap/RateChangedDialog.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; +import { FONTS } from '../../theme'; +import { formatRate } from '../../utils/format'; +import type { RateChangedError } from '../../api/SwapApiClient'; + +interface Props { + open: boolean; + data?: RateChangedError; + onAccept: () => void; + onCancel: () => void; +} + +const RateChangedDialog: React.FC = ({ + open, + data, + onAccept, + onCancel, +}) => ( + + + Rate changed + + + + + The miner's rate moved between your quote and the reservation request. + Zero-tolerance — you must re-accept the new rate before continuing. + + {data && ( + + + Quoted + + + {formatRate(data.expected)} + + + New + + + {formatRate(data.actual)} + + + )} + + + + + + + +); + +export default RateChangedDialog; diff --git a/src/components/swap/SwapDetails.tsx b/src/components/swap/SwapDetails.tsx new file mode 100644 index 0000000..4fce6ad --- /dev/null +++ b/src/components/swap/SwapDetails.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Stack, + Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { FONTS } from '../../theme'; +import type { BestMinerResponse } from '../../api/SwapApiClient'; +import { formatRate, shortAddr } from '../../utils/format'; +import type { SwapFlowState } from '../../hooks/useSwapFlow'; + +interface Props { + best?: BestMinerResponse; + state: SwapFlowState; +} + +const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => ( + + + {label} + + + {value} + + +); + +const SwapDetails: React.FC = ({ best, state }) => { + if (!best && !state.reserve) return null; + return ( + + }> + + Swap details + + + + + {best && } + {best && } + {state.reserve && ( + + )} + {state.reserve && ( + + )} + {state.sourceTxHash && ( + + )} + {state.swapId !== undefined && ( + + )} + + + + ); +}; + +export default SwapDetails; diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx new file mode 100644 index 0000000..8183eb9 --- /dev/null +++ b/src/components/swap/SwapForm.tsx @@ -0,0 +1,303 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Stack, + TextField, + Typography, +} from '@mui/material'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import { FONTS } from '../../theme'; +import { useBestMiner, useChains } from '../../api/SwapApi'; +import { formatRate } from '../../utils/format'; +import TokenInput from './TokenInput'; +import { useWallet } from '../../wallet/WalletProvider'; +import type { BestMinerResponse } from '../../api/SwapApiClient'; + +export interface SwapFormSubmit { + fromChain: string; + toChain: string; + /** Smallest-unit amount the user is sending. */ + fromAmount: number; + /** Smallest-unit amount the user expects to receive (gross, before 1% fee). */ + toAmount: number; + taoAmount: number; + fromAddress: string; + toAddress: string; + best: BestMinerResponse; + blockAnchor: number; +} + +interface ChainSpec { + id: string; + decimals: number; + symbol: string; +} + +const STATIC_CHAINS: Record = { + btc: { id: 'btc', decimals: 8, symbol: 'BTC' }, + tao: { id: 'tao', decimals: 9, symbol: 'TAO' }, +}; + +const toSmallest = (human: string, decimals: number): number => { + const n = parseFloat(human); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.floor(n * 10 ** decimals); +}; + +const fromSmallest = (n: number, decimals: number): string => { + if (!Number.isFinite(n) || n <= 0) return '0.0'; + return (n / 10 ** decimals).toString(); +}; + +interface Props { + onSubmit: (input: SwapFormSubmit) => void; + /** Set true when the parent has an in-flight swap and shouldn't accept new submits. */ + disabled?: boolean; + onOpenConnect: (requireSubstrate: boolean) => void; +} + +const SwapForm: React.FC = ({ onSubmit, disabled, onOpenConnect }) => { + const { substrate, bitcoin } = useWallet(); + const chainsQuery = useChains(); + + const supportedIds = useMemo(() => { + if (chainsQuery.data?.chains?.length) { + return chainsQuery.data.chains.map((c) => c.id); + } + return ['btc', 'tao']; + }, [chainsQuery.data]); + + const [fromChain, setFromChain] = useState('btc'); + const [toChain, setToChain] = useState('tao'); + const [amountStr, setAmountStr] = useState(''); + const [toAddress, setToAddress] = useState(''); + + // Keep `toChain` in sync if from changes. + useEffect(() => { + if (toChain === fromChain) { + const other = supportedIds.find((id) => id !== fromChain); + if (other) setToChain(other); + } + }, [fromChain, toChain, supportedIds]); + + const fromSpec = STATIC_CHAINS[fromChain] ?? STATIC_CHAINS.btc; + const toSpec = STATIC_CHAINS[toChain] ?? STATIC_CHAINS.tao; + + const fromAmount = toSmallest(amountStr, fromSpec.decimals); + const best = useBestMiner(fromChain, toChain, fromAmount, fromAmount > 0); + + const expectedHuman = useMemo(() => { + if (!best.data) return ''; + return fromSmallest(best.data.expectedOut, toSpec.decimals); + }, [best.data, toSpec.decimals]); + + const fromAddress = + fromChain === 'btc' ? (bitcoin?.address ?? '') : (substrate?.address ?? ''); + const autoToAddress = + toChain === 'btc' ? (bitcoin?.address ?? '') : (substrate?.address ?? ''); + + // Default destination to the user's connected dest-chain wallet when present. + useEffect(() => { + if (!toAddress && autoToAddress) setToAddress(autoToAddress); + }, [autoToAddress, toAddress]); + + const sourceConnected = fromChain === 'btc' ? !!bitcoin : !!substrate; + + const submitLabel = (() => { + if (!sourceConnected) { + return `Connect ${fromSpec.symbol} wallet`; + } + if (!toAddress) return 'Enter destination address'; + if (!amountStr || fromAmount <= 0) return 'Enter amount'; + if (best.isFetching && !best.data) return 'Fetching quote…'; + if (best.isError) return 'No miner available'; + if (!best.data) return 'Quote unavailable'; + return 'Review & Reserve'; + })(); + + const canSubmit = + sourceConnected && + !!toAddress && + fromAmount > 0 && + !!best.data && + !disabled; + + const handleSubmit = () => { + if (!sourceConnected) { + onOpenConnect(fromChain === 'tao'); + return; + } + if (!canSubmit || !best.data) return; + + // tao_amount is always the TAO side in rao regardless of direction (project invariant). + const taoAmount = fromChain === 'tao' ? fromAmount : best.data.expectedOut; // BTC→TAO: expectedOut is TAO (gross, pre-fee) + + onSubmit({ + fromChain, + toChain, + fromAmount, + toAmount: best.data.expectedOut, + taoAmount, + fromAddress, + toAddress, + best: best.data, + blockAnchor: best.data.freshAsOf, + }); + }; + + const flipChains = () => { + const f = fromChain; + setFromChain(toChain); + setToChain(f); + setAmountStr(''); + setToAddress(''); + }; + + return ( + + + Exchange + + + STATIC_CHAINS[id]?.symbol ?? id.toUpperCase(), + )} + onSymbolChange={(sym) => { + const id = supportedIds.find( + (cid) => (STATIC_CHAINS[cid]?.symbol ?? cid.toUpperCase()) === sym, + ); + if (id) setFromChain(id); + }} + /> + + + + + + STATIC_CHAINS[id]?.symbol ?? id.toUpperCase(), + )} + onSymbolChange={(sym) => { + const id = supportedIds.find( + (cid) => (STATIC_CHAINS[cid]?.symbol ?? cid.toUpperCase()) === sym, + ); + if (id) setToChain(id); + }} + /> + + setToAddress(e.target.value)} + size="small" + fullWidth + sx={{ + mt: 1, + '& .MuiInputBase-input': { + fontFamily: FONTS.mono, + fontSize: '0.8rem', + }, + }} + InputLabelProps={{ + sx: { fontFamily: FONTS.mono, fontSize: '0.75rem' }, + }} + /> + + + + Rate + + + {best.data + ? `${formatRate(best.data.rate)} ${toSpec.symbol} / ${fromSpec.symbol}` + : `— ${toSpec.symbol} / ${fromSpec.symbol}`} + + + + {best.isError && ( + + No miner is currently quoting {fromSpec.symbol} → {toSpec.symbol}. + + )} + + + + ); +}; + +export default SwapForm; diff --git a/src/components/swap/SwapProgress.tsx b/src/components/swap/SwapProgress.tsx new file mode 100644 index 0000000..59f7733 --- /dev/null +++ b/src/components/swap/SwapProgress.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import { FONTS } from '../../theme'; +import type { SwapPhase } from '../../hooks/useSwapFlow'; + +type StepState = 'pending' | 'active' | 'done' | 'failed'; + +const STEPS: Array<{ key: string; label: string; phases: SwapPhase[] }> = [ + { + key: 'reserve', + label: 'Reserve', + phases: ['awaitingReserveSig', 'reserving'], + }, + { key: 'send', label: 'Send funds', phases: ['awaitingSend', 'sending'] }, + { + key: 'confirm', + label: 'Confirm', + phases: ['awaitingConfirmSig', 'confirming'], + }, + { key: 'complete', label: 'Complete', phases: ['watching'] }, +]; + +const computeStates = (phase: SwapPhase): StepState[] => { + if (phase === 'idle') return STEPS.map(() => 'pending'); + if (phase === 'error') return STEPS.map(() => 'failed'); + if (phase === 'done') return STEPS.map(() => 'done'); + + const result: StepState[] = []; + let seenActive = false; + for (const step of STEPS) { + if (step.phases.includes(phase)) { + result.push('active'); + seenActive = true; + } else if (seenActive) { + result.push('pending'); + } else { + result.push('done'); + } + } + return result; +}; + +const SwapProgress: React.FC<{ phase: SwapPhase; error?: string }> = ({ + phase, + error, +}) => { + const states = computeStates(phase); + return ( + + + Progress + + {STEPS.map((step, i) => { + const state = states[i]; + return ( + + + {state === 'done' ? ( + + ) : state === 'failed' ? ( + + ) : ( + i + 1 + )} + + + {step.label} + + + ); + })} + {error && ( + + {error} + + )} + + ); +}; + +export default SwapProgress; diff --git a/src/components/swap/TokenInput.tsx b/src/components/swap/TokenInput.tsx index f3d9b0c..afe039c 100644 --- a/src/components/swap/TokenInput.tsx +++ b/src/components/swap/TokenInput.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Stack, TextField, Typography } from '@mui/material'; +import { Stack, TextField, Typography, MenuItem, Select } from '@mui/material'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { FONTS } from '../../theme'; @@ -9,6 +9,9 @@ export interface TokenInputProps { balance: string; amount?: string; readOnly?: boolean; + onAmountChange?: (value: string) => void; + symbolOptions?: string[]; + onSymbolChange?: (next: string) => void; } const TokenInput: React.FC = ({ @@ -17,6 +20,9 @@ const TokenInput: React.FC = ({ balance, amount = '0.0', readOnly = true, + onAmountChange, + symbolOptions, + onSymbolChange, }) => ( = ({ onAmountChange(e.target.value) : undefined + } variant="standard" InputProps={{ disableUnderline: true, @@ -55,29 +64,55 @@ const TokenInput: React.FC = ({ }} sx={{ flex: 1 }} /> - - onSymbolChange(String(e.target.value))} sx={{ + px: 1.25, + py: 0.25, + border: '1px solid', + borderColor: 'divider', fontFamily: FONTS.mono, fontWeight: 700, fontSize: '0.85rem', letterSpacing: '0.05em', + '& .MuiSelect-select': { paddingRight: '24px !important' }, + }} + > + {symbolOptions.map((opt) => ( + + {opt} + + ))} + + ) : ( + - {symbol} - - - + + {symbol} + + + + )} ); diff --git a/src/components/swap/index.ts b/src/components/swap/index.ts index cc7abe3..6608ce5 100644 --- a/src/components/swap/index.ts +++ b/src/components/swap/index.ts @@ -1,2 +1,8 @@ export { default as TokenInput } from './TokenInput'; export type { TokenInputProps } from './TokenInput'; +export { default as SwapForm } from './SwapForm'; +export type { SwapFormSubmit } from './SwapForm'; +export { default as SwapProgress } from './SwapProgress'; +export { default as SwapDetails } from './SwapDetails'; +export { default as RateChangedDialog } from './RateChangedDialog'; +export { default as ClaimSlashedButton } from './ClaimSlashedButton'; diff --git a/src/env.d.ts b/src/env.d.ts index d294512..ec1f34b 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,6 +1,9 @@ /// interface ImportMetaEnv { readonly VITE_REACT_APP_BASE_URL: string; + readonly VITE_SWAP_API_URL?: string; + readonly VITE_SUBTENSOR_WS_URL?: string; + readonly VITE_EXPLORER_EXTRINSIC_URL?: string; } interface ImportMeta { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a54ded..c58a6d8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,3 +2,7 @@ export { useOnNavigate } from './useOnNavigate'; export { useSSE } from './useSSE'; export { useCopy } from './useCopy'; export type { UseCopyResult } from './useCopy'; +export { usePendingSwap } from './usePendingSwap'; +export type { PendingSwap } from './usePendingSwap'; +export { useSwapFlow } from './useSwapFlow'; +export type { SwapFlowState, SwapInputs, SwapPhase } from './useSwapFlow'; diff --git a/src/hooks/usePendingSwap.ts b/src/hooks/usePendingSwap.ts new file mode 100644 index 0000000..59780d9 --- /dev/null +++ b/src/hooks/usePendingSwap.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Mirror of the CLI's `pending_swap.json` — keyed on + * (fromAddress, miner, blockAnchor) so per-user disambiguation works (spec §4c). + */ +export interface PendingSwap { + /** Set when the user submitted the reserve mutation. */ + requestHash?: string; + minerHotkey: string; + minerSourceAddress?: string; + fromAddress: string; + toAddress: string; + fromChain: string; + toChain: string; + fromAmount: number; + toAmount: number; + taoAmount: number; + expectedRate: string; + blockAnchor: number; + /** Cached reserve proof signature so a reload can re-submit without re-prompting. */ + reserveSignature?: string; + /** Source-side tx hash, once funds have been sent. */ + sourceTxHash?: string; + /** Reservation expiry. Used by the resume flow to decide stale vs ACTIVE. */ + reservedUntilBlock?: number; + /** Final on-chain swap id after confirm. */ + swapId?: number; + /** ISO timestamp of last write. */ + updatedAt: string; +} + +const STORAGE_KEY = 'allways.pendingSwap'; + +const readStorage = (): PendingSwap | null => { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as PendingSwap; + } catch { + return null; + } +}; + +const writeStorage = (value: PendingSwap | null): void => { + if (typeof window === 'undefined') return; + try { + if (value === null) window.localStorage.removeItem(STORAGE_KEY); + else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + /* quota / private-mode — silently ignore */ + } +}; + +export const usePendingSwap = () => { + const [pending, setPending] = useState(() => + readStorage(), + ); + + // Keep tabs roughly in sync — re-read on `storage` events. Full + // BroadcastChannel ownership arbitration is a v2 nice-to-have (spec §9 + // "Multi-tab races"). + useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key !== STORAGE_KEY) return; + setPending(readStorage()); + }; + window.addEventListener('storage', handler); + return () => window.removeEventListener('storage', handler); + }, []); + + const save = useCallback((value: Omit) => { + const next: PendingSwap = { ...value, updatedAt: new Date().toISOString() }; + writeStorage(next); + setPending(next); + }, []); + + const merge = useCallback((patch: Partial) => { + setPending((prev) => { + if (!prev) return prev; + const next: PendingSwap = { + ...prev, + ...patch, + updatedAt: new Date().toISOString(), + }; + writeStorage(next); + return next; + }); + }, []); + + const clear = useCallback(() => { + writeStorage(null); + setPending(null); + }, []); + + return { pending, save, merge, clear }; +}; diff --git a/src/hooks/useSwapFlow.ts b/src/hooks/useSwapFlow.ts new file mode 100644 index 0000000..5916ccc --- /dev/null +++ b/src/hooks/useSwapFlow.ts @@ -0,0 +1,270 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { AxiosError } from 'axios'; +import { + fetchConfirmProof, + fetchReserveProof, + isRateChangedError, + useConfirmMutation, + useReserveMutation, +} from '../api/SwapApi'; +import type { + BestMinerResponse, + RateChangedError, + ReserveResponse, +} from '../api/SwapApiClient'; +import type { BitcoinConnection } from '../wallet/bitcoin'; +import type { SubstrateConnection } from '../wallet/substrate'; +import { usePendingSwap, type PendingSwap } from './usePendingSwap'; + +/* ---------- State ---------- */ + +export type SwapPhase = + | 'idle' + | 'awaitingReserveSig' + | 'reserving' + | 'reserved' + | 'awaitingSend' + | 'sending' + | 'awaitingConfirmSig' + | 'confirming' + | 'watching' + | 'done' + | 'error'; + +export interface SwapFlowState { + phase: SwapPhase; + reserve?: ReserveResponse; + swapId?: number; + sourceTxHash?: string; + error?: string; + rateChanged?: RateChangedError; +} + +export interface SwapInputs { + best: BestMinerResponse; + fromChain: string; + toChain: string; + fromAmount: number; + toAmount: number; + taoAmount: number; + fromAddress: string; + /** Destination for the user's receive side. */ + toAddress: string; + blockAnchor: number; +} + +/* ---------- Internal signer abstraction ---------- */ + +interface SourceSigner { + signMessage: (msg: string) => Promise; + sendFunds: (to: string, amount: number) => Promise; +} + +const wrapSigner = ( + chain: string, + bitcoin: BitcoinConnection | null, + substrate: SubstrateConnection | null, +): SourceSigner | null => { + const c = chain.toLowerCase(); + if (c === 'btc' && bitcoin) { + return { + signMessage: (msg) => bitcoin.signMessage(msg), + sendFunds: (to, sats) => bitcoin.sendBitcoin(to, sats), + }; + } + if (c === 'tao' && substrate) { + return { + signMessage: (msg) => substrate.signRaw(msg), + sendFunds: () => + Promise.reject( + new Error( + 'TAO-source send not implemented in v1 — use the CLI for now (`alw swap`).', + ), + ), + }; + } + return null; +}; + +/* ---------- Hook ---------- */ + +export const useSwapFlow = (params: { + substrate: SubstrateConnection | null; + bitcoin: BitcoinConnection | null; +}) => { + const { substrate, bitcoin } = params; + const [state, setState] = useState({ phase: 'idle' }); + const reserveMut = useReserveMutation(); + const confirmMut = useConfirmMutation(); + const { pending, save, merge, clear } = usePendingSwap(); + + const reset = useCallback(() => { + setState({ phase: 'idle' }); + reserveMut.reset(); + confirmMut.reset(); + }, [reserveMut, confirmMut]); + + const begin = useCallback( + async (inputs: SwapInputs) => { + try { + const signer = wrapSigner(inputs.fromChain, bitcoin, substrate); + if (!signer) { + throw new Error( + `No connected wallet for ${inputs.fromChain.toUpperCase()} source chain`, + ); + } + + // Persist intent FIRST — protects against a refresh between sign and + // reserve broadcast. blockAnchor + miner + fromAddress is the resume key. + save({ + minerHotkey: inputs.best.minerHotkey, + fromAddress: inputs.fromAddress, + toAddress: inputs.toAddress, + fromChain: inputs.fromChain, + toChain: inputs.toChain, + fromAmount: inputs.fromAmount, + toAmount: inputs.toAmount, + taoAmount: inputs.taoAmount, + expectedRate: inputs.best.rate, + blockAnchor: inputs.blockAnchor, + }); + + setState({ phase: 'awaitingReserveSig' }); + const proof = await fetchReserveProof( + inputs.fromAddress, + inputs.blockAnchor, + ); + const signature = await signer.signMessage(proof.message); + merge({ reserveSignature: signature }); + + setState({ phase: 'reserving' }); + const reserve = await reserveMut.mutateAsync({ + minerHotkey: inputs.best.minerHotkey, + fromChain: inputs.fromChain, + toChain: inputs.toChain, + taoAmount: inputs.taoAmount, + fromAmount: inputs.fromAmount, + toAmount: inputs.toAmount, + fromAddress: inputs.fromAddress, + fromAddressProof: signature, + blockAnchor: inputs.blockAnchor, + expectedRate: inputs.best.rate, + }); + merge({ + requestHash: reserve.requestHash, + minerSourceAddress: reserve.minerSourceAddress, + reservedUntilBlock: reserve.reservedUntilBlock, + }); + setState({ phase: 'awaitingSend', reserve }); + } catch (err) { + const axiosErr = err as AxiosError< + RateChangedError | { detail: string } + >; + if (isRateChangedError(axiosErr) && axiosErr.response) { + setState({ + phase: 'error', + rateChanged: axiosErr.response.data as RateChangedError, + error: 'Miner rate changed since quote', + }); + return; + } + setState({ + phase: 'error', + error: (err as Error).message ?? 'Reservation failed', + }); + } + }, + [bitcoin, merge, reserveMut, save, substrate], + ); + + const sendAndConfirm = useCallback(async () => { + if (!pending || !state.reserve) { + setState({ phase: 'error', error: 'No active reservation' }); + return; + } + try { + const signer = wrapSigner(pending.fromChain, bitcoin, substrate); + if (!signer) { + throw new Error( + `No connected wallet for ${pending.fromChain.toUpperCase()} source chain`, + ); + } + + setState({ phase: 'sending', reserve: state.reserve }); + const txHash = + pending.sourceTxHash ?? + (await signer.sendFunds( + state.reserve.minerSourceAddress, + pending.fromAmount, + )); + merge({ sourceTxHash: txHash }); + + setState({ + phase: 'awaitingConfirmSig', + reserve: state.reserve, + sourceTxHash: txHash, + }); + const proof = await fetchConfirmProof(txHash); + const signature = await signer.signMessage(proof.message); + + setState({ + phase: 'confirming', + reserve: state.reserve, + sourceTxHash: txHash, + }); + const confirm = await confirmMut.mutateAsync({ + requestHash: state.reserve.requestHash, + minerHotkey: state.reserve.minerHotkey, + fromTxHash: txHash, + fromTxProof: signature, + fromAddress: pending.fromAddress, + toAddress: pending.toAddress, + fromChain: pending.fromChain, + toChain: pending.toChain, + fromTxBlock: 0, + }); + + if (!confirm.accepted) { + setState({ + phase: 'error', + error: confirm.rejection ?? 'Validators rejected confirm', + }); + return; + } + + if (confirm.swapId !== undefined) { + merge({ swapId: confirm.swapId }); + } + setState({ + phase: 'watching', + reserve: state.reserve, + sourceTxHash: txHash, + swapId: confirm.swapId, + }); + } catch (err) { + setState({ + phase: 'error', + error: (err as Error).message ?? 'Confirm failed', + }); + } + }, [bitcoin, confirmMut, merge, pending, state.reserve, substrate]); + + const markDone = useCallback(() => { + setState((s) => ({ ...s, phase: 'done' })); + }, []); + + return useMemo( + () => ({ + state, + pending, + begin, + sendAndConfirm, + reset, + clear, + markDone, + }), + [state, pending, begin, sendAndConfirm, reset, clear, markDone], + ); +}; + +export type { PendingSwap }; diff --git a/src/pages/MySwapsPage.tsx b/src/pages/MySwapsPage.tsx new file mode 100644 index 0000000..34cdf32 --- /dev/null +++ b/src/pages/MySwapsPage.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { Alert, Stack, Typography } from '@mui/material'; +import { Card, Page, SEO } from '../components'; +import { useAllSwaps } from '../api/SwapsApi'; +import { useWallet } from '../wallet/WalletProvider'; +import { FONTS } from '../theme'; +import { shortAddr } from '../utils/format'; + +const MySwapsPage: React.FC = () => { + const { substrate, bitcoin } = useWallet(); + // Filter by whichever address we have; das-allways `search` matches both + // user and source/dest sides. + const search = substrate?.address ?? bitcoin?.address ?? ''; + const { data, isLoading } = useAllSwaps({ search, limit: 50 }, !!search); + + return ( + + + + + My swaps + + + {!search && ( + + Connect a wallet on the Exchange page to see swaps tied to your + address. + + )} + + {search && isLoading && ( + + Loading… + + )} + + {search && !isLoading && (data ?? []).length === 0 && ( + + No swaps yet for {shortAddr(search)}. + + )} + + {(data ?? []).map((swap) => ( + + + + + #{swap.swapId} · {swap.sourceChain?.toUpperCase()} + {' → '} + {swap.destChain?.toUpperCase()} + + {swap.minerHotkey && ( + + miner {shortAddr(swap.minerHotkey)} + + )} + + + {swap.status} + + + + ))} + + + ); +}; + +export default MySwapsPage; diff --git a/src/pages/SwapDetailPage.tsx b/src/pages/SwapDetailPage.tsx index 1bca50b..bb52137 100644 --- a/src/pages/SwapDetailPage.tsx +++ b/src/pages/SwapDetailPage.tsx @@ -39,6 +39,7 @@ import { type ContractEvent } from '../api/models'; import ExtensionChip, { deriveSwapExtensionStatus, } from '../components/ExtensionChip'; +import { ClaimSlashedButton } from '../components/swap'; type SwapStep = { label: string; @@ -375,6 +376,19 @@ const SwapDetailPage: React.FC = () => { + {/* In-browser claim for slashed swaps — only shown for TIMED_OUT swaps + where the claim is still pending. Requires Substrate wallet; falls + back to a CLI hint otherwise (spec §5 / §9). */} + {isTimedOut && refundPending && ( + + Claim + + + )} + {/* Refund (timed-out slash) */} {refundEvent && ( diff --git a/src/pages/SwapPage.tsx b/src/pages/SwapPage.tsx index 64f8f35..efdc8f7 100644 --- a/src/pages/SwapPage.tsx +++ b/src/pages/SwapPage.tsx @@ -1,15 +1,79 @@ -import React from 'react'; -import { Box, Button, Link, Stack, Typography } from '@mui/material'; -import { Link as RouterLink } from 'react-router-dom'; -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Stack } from '@mui/material'; +import { Page, SEO } from '../components'; +import { + ClaimSlashedButton, + RateChangedDialog, + SwapDetails, + SwapForm, + SwapProgress, +} from '../components/swap'; +import { + ConnectWalletDialog, + SubstrateOptionalBanner, + useWallet, +} from '../wallet'; +import { useSwapFlow, type SwapInputs } from '../hooks/useSwapFlow'; +import { useSwapDetail } from '../api/SwapsApi'; import { FONTS } from '../theme'; -import { Page, SEO, TokenInput } from '../components'; -import { docsUrl } from '../components/nav/links'; +import type { BestMinerResponse } from '../api/SwapApiClient'; const SwapPage: React.FC = () => { - const swapGuideUrl = `${docsUrl()}swap-guide`; + const { substrate, bitcoin } = useWallet(); + const [connectOpen, setConnectOpen] = useState(false); + const [requireSubstrate, setRequireSubstrate] = useState(false); + const [pendingInputs, setPendingInputs] = useState(null); + const flow = useSwapFlow({ substrate, bitcoin }); + + // Auto-confirm: once we reach `awaitingSend`, push directly through + // sendAndConfirm so the wallet handles the BTC send popup. The state machine + // pauses at `sending` if the user rejects in-wallet. + useEffect(() => { + if (flow.state.phase === 'awaitingSend') { + void flow.sendAndConfirm(); + } + }, [flow]); + + const handleSubmit = (input: SwapInputs) => { + setPendingInputs(input); + void flow.begin(input); + }; + + const onOpenConnect = (forSubstrate: boolean) => { + setRequireSubstrate(forSubstrate); + setConnectOpen(true); + }; + + const handleRateAccept = () => { + if (!pendingInputs) return; + // Carry forward inputs but with the new live rate the user just accepted. + const actual = flow.state.rateChanged?.actual ?? pendingInputs.best.rate; + const adjustedBest: BestMinerResponse = { + ...pendingInputs.best, + rate: actual, + }; + flow.reset(); + void flow.begin({ ...pendingInputs, best: adjustedBest }); + }; + + const watchSwapId = useMemo(() => { + if (flow.state.swapId !== undefined) return String(flow.state.swapId); + return ''; + }, [flow.state.swapId]); + + // Watch the active swap once we know its id — flips phase to `done` on COMPLETED. + const swapDetail = useSwapDetail(watchSwapId); + useEffect(() => { + const status = swapDetail.data?.swap?.status; + if (status === 'COMPLETED' && flow.state.phase === 'watching') { + flow.markDone(); + } + }, [swapDetail.data, flow]); + + const isTimedOut = swapDetail.data?.swap?.status === 'TIMED_OUT'; + return ( - + { maxWidth: 480, mx: 'auto', px: 2, - py: { xs: 6, md: 10 }, + py: { xs: 4, md: 6 }, flex: 1, + gap: 2, }} > - - {/* Card (greyed + blurred, non-interactive) */} - + + + + {flow.state.phase !== 'idle' && ( + + )} + + + + {isTimedOut && flow.state.swapId !== undefined && ( + + )} + + {(flow.state.phase === 'done' || flow.state.phase === 'error') && ( + - - - {/* Coming Soon overlay */} - - - Coming Soon - - - In-browser exchanges land soon. Today, exchange with the{' '} - - CLI - {' '} - or bring an agent —{' '} - - get the agent bundle → - - - - + New swap + + )} + + setConnectOpen(false)} + requireSubstrate={requireSubstrate} + /> + + flow.reset()} + /> ); }; diff --git a/src/pages/index.ts b/src/pages/index.ts index 68f7cdc..b4e9914 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -4,3 +4,4 @@ export { default as SwapPage } from './SwapPage'; export { default as AgentsPage } from './AgentsPage'; export { default as NotFoundPage } from './NotFoundPage'; export { default as LoadingPage } from './LoadingPage'; +export { default as MySwapsPage } from './MySwapsPage'; diff --git a/src/routes.tsx b/src/routes.tsx index 9601e63..1113872 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -17,6 +17,7 @@ const ReservationsBySourcePage = React.lazy( () => import('./pages/ReservationsBySourcePage'), ); const AgentsPage = React.lazy(() => import('./pages/AgentsPage')); +const MySwapsPage = React.lazy(() => import('./pages/MySwapsPage')); const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage')); const routesArray: AppRoute[] = [ @@ -35,6 +36,7 @@ const routesArray: AppRoute[] = [ element: , }, { name: 'agents', path: '/agents', element: }, + { name: 'my-swaps', path: '/my-swaps', element: }, // 404 catch-all route (must be last) { diff --git a/src/wallet/ConnectWalletDialog.tsx b/src/wallet/ConnectWalletDialog.tsx new file mode 100644 index 0000000..ae9003f --- /dev/null +++ b/src/wallet/ConnectWalletDialog.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material'; +import { FONTS } from '../theme'; +import { useWallet } from './WalletProvider'; +import { detectSubstrateExtensions } from './substrate'; +import { detectBitcoinExtensions, type BitcoinSource } from './bitcoin'; + +interface ConnectWalletDialogProps { + open: boolean; + onClose: () => void; + /** Force the user to connect Substrate before closing (TAO-source swaps). */ + requireSubstrate?: boolean; +} + +const KNOWN_SUBSTRATE: Record = { + 'polkadot-js': 'Polkadot.js', + talisman: 'Talisman', + 'subwallet-js': 'SubWallet', +}; + +const KNOWN_BITCOIN: Record = { + unisat: 'Unisat', + xverse: 'Xverse', + leather: 'Leather', +}; + +// Only Unisat is fully wired in v1 — others are detection-only. +const FULLY_WIRED: ReadonlyArray = ['unisat']; + +const ConnectWalletDialog: React.FC = ({ + open, + onClose, + requireSubstrate = false, +}) => { + const { substrate, bitcoin, connectSubstrateWallet, connectBitcoinWallet } = + useWallet(); + const [substrateExtensions, setSubstrateExtensions] = useState([]); + const [bitcoinExtensions, setBitcoinExtensions] = useState( + [], + ); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open) { + setSubstrateExtensions(detectSubstrateExtensions()); + setBitcoinExtensions(detectBitcoinExtensions()); + setError(null); + } + }, [open]); + + const handleSubstrate = async () => { + setBusy(true); + setError(null); + try { + await connectSubstrateWallet(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + const handleBitcoin = async (source: BitcoinSource) => { + setBusy(true); + setError(null); + try { + await connectBitcoinWallet(source); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + const canClose = useMemo(() => { + if (requireSubstrate) return !!substrate; + return !!substrate || !!bitcoin; + }, [requireSubstrate, substrate, bitcoin]); + + return ( + + + Connect wallets + + + + {error && {error}} + + + + Substrate (TAO) + + {substrate ? ( + + Connected: {substrate.address.slice(0, 8)}… + {substrate.address.slice(-6)} + + ) : substrateExtensions.length === 0 ? ( + + No Substrate extension detected. Install Polkadot.js, Talisman, + or SubWallet. + + ) : ( + + + Detected:{' '} + {substrateExtensions + .map((s) => KNOWN_SUBSTRATE[s] ?? s) + .join(', ')} + + + + )} + + + + + Bitcoin + + {bitcoin ? ( + + Connected: {KNOWN_BITCOIN[bitcoin.source]} —{' '} + {bitcoin.address.slice(0, 8)}…{bitcoin.address.slice(-6)} + + ) : bitcoinExtensions.length === 0 ? ( + + No Bitcoin wallet detected. Install Unisat (Xverse / Leather + coming soon). + + ) : ( + + {bitcoinExtensions.map((src) => { + const wired = FULLY_WIRED.includes(src); + return ( + + ); + })} + + )} + + + {requireSubstrate && !substrate && ( + + TAO-source swaps require a Substrate wallet. Connect one to + continue. + + )} + + + + + + + ); +}; + +export default ConnectWalletDialog; diff --git a/src/wallet/SubstrateOptionalBanner.tsx b/src/wallet/SubstrateOptionalBanner.tsx new file mode 100644 index 0000000..1d7a276 --- /dev/null +++ b/src/wallet/SubstrateOptionalBanner.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Alert, Button, Stack, Typography } from '@mui/material'; +import { FONTS } from '../theme'; +import { useWallet } from './WalletProvider'; + +/** + * Banner shown once when a BTC-source user has no Substrate wallet connected. + * Surfaces the tradeoff: claim path falls back to CLI if the miner is slashed. + * See spec §7 — wallet rules. + */ +const SubstrateOptionalBanner: React.FC = () => { + const { + substrate, + acknowledgedSubstrateOptional, + acknowledgeSubstrateOptional, + } = useWallet(); + + if (substrate || acknowledgedSubstrateOptional) return null; + + return ( + + + + No Substrate wallet connected. You can still swap BTC → TAO by pasting + a TAO destination address. + + + If the miner is slashed, you'll need the alw claim CLI + (or to connect a Substrate wallet later) to recover funds. + + + + + ); +}; + +export default SubstrateOptionalBanner; diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx new file mode 100644 index 0000000..c46fd13 --- /dev/null +++ b/src/wallet/WalletProvider.tsx @@ -0,0 +1,86 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { + connectBitcoin, + type BitcoinConnection, + type BitcoinSource, +} from './bitcoin'; +import { connectSubstrate, type SubstrateConnection } from './substrate'; + +interface WalletContextValue { + substrate: SubstrateConnection | null; + bitcoin: BitcoinConnection | null; + /** True after the user has dismissed the "no Substrate wallet" banner. */ + acknowledgedSubstrateOptional: boolean; + connectSubstrateWallet: () => Promise; + connectBitcoinWallet: (source: BitcoinSource) => Promise; + disconnect: () => void; + acknowledgeSubstrateOptional: () => void; +} + +const WalletContext = createContext(null); + +export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [substrate, setSubstrate] = useState(null); + const [bitcoin, setBitcoin] = useState(null); + const [acknowledgedSubstrateOptional, setAcknowledged] = useState(false); + + const connectSubstrateWallet = useCallback(async () => { + const conn = await connectSubstrate(); + setSubstrate(conn); + return conn; + }, []); + + const connectBitcoinWallet = useCallback(async (source: BitcoinSource) => { + const conn = await connectBitcoin(source); + setBitcoin(conn); + return conn; + }, []); + + const disconnect = useCallback(() => { + setSubstrate(null); + setBitcoin(null); + }, []); + + const acknowledgeSubstrateOptional = useCallback(() => { + setAcknowledged(true); + }, []); + + const value = useMemo( + () => ({ + substrate, + bitcoin, + acknowledgedSubstrateOptional, + connectSubstrateWallet, + connectBitcoinWallet, + disconnect, + acknowledgeSubstrateOptional, + }), + [ + substrate, + bitcoin, + acknowledgedSubstrateOptional, + connectSubstrateWallet, + connectBitcoinWallet, + disconnect, + acknowledgeSubstrateOptional, + ], + ); + + return ( + {children} + ); +}; + +export const useWallet = (): WalletContextValue => { + const ctx = useContext(WalletContext); + if (!ctx) throw new Error('useWallet must be used inside '); + return ctx; +}; diff --git a/src/wallet/bitcoin.ts b/src/wallet/bitcoin.ts new file mode 100644 index 0000000..a46a4bb --- /dev/null +++ b/src/wallet/bitcoin.ts @@ -0,0 +1,96 @@ +/** + * Bitcoin wallet adapters — Unisat (full), Xverse / Leather (detection only). + * + * Lazy: all `window.*` reads happen inside the connect helpers. Adapters are + * factored so a v2 PR can fill in Xverse / Leather signing without changing + * the WalletProvider contract. + */ + +export type BitcoinSource = 'unisat' | 'xverse' | 'leather'; + +export interface BitcoinConnection { + address: string; + source: BitcoinSource; + /** Returns the wallet's signature for the canonical proof message. */ + signMessage: (message: string) => Promise; + /** Broadcasts a transfer; returns the tx hash. `sats` is the satoshi amount. */ + sendBitcoin: (to: string, sats: number) => Promise; +} + +interface UnisatProvider { + requestAccounts: () => Promise; + getAccounts: () => Promise; + signMessage: ( + msg: string, + type?: 'ecdsa' | 'bip322-simple', + ) => Promise; + sendBitcoin: (to: string, sats: number) => Promise; + getNetwork: () => Promise; +} + +const getUnisat = (): UnisatProvider | null => { + if (typeof window === 'undefined') return null; + return (window as unknown as { unisat?: UnisatProvider }).unisat ?? null; +}; + +const getXverseProviders = () => { + if (typeof window === 'undefined') return null; + return ( + (window as unknown as { XverseProviders?: unknown }).XverseProviders ?? null + ); +}; + +const getLeather = () => { + if (typeof window === 'undefined') return null; + return ( + (window as unknown as { LeatherProvider?: unknown }).LeatherProvider ?? null + ); +}; + +export const detectBitcoinExtensions = (): BitcoinSource[] => { + const found: BitcoinSource[] = []; + if (getUnisat()) found.push('unisat'); + if (getXverseProviders()) found.push('xverse'); + if (getLeather()) found.push('leather'); + return found; +}; + +const connectUnisat = async (): Promise => { + const u = getUnisat(); + if (!u) throw new Error('Unisat extension not detected'); + // `requestAccounts()` triggers the unlock popup; `getAccounts()` returns [] + // until the user approves the dapp the first time. + const accounts = await u.requestAccounts(); + if (accounts.length === 0) throw new Error('Unisat returned no accounts'); + const address = accounts[0]; + return { + address, + source: 'unisat', + signMessage: (msg) => u.signMessage(msg), + sendBitcoin: (to, sats) => u.sendBitcoin(to, sats), + }; +}; + +const stubAdapter = (source: BitcoinSource): BitcoinConnection => { + const reject = (op: string) => () => + Promise.reject( + new Error( + `${source} support is stubbed in v1 — use Unisat for now (or finish the adapter to unblock ${op}).`, + ), + ); + return { + address: '', + source, + signMessage: reject('signMessage'), + sendBitcoin: reject('sendBitcoin'), + }; +}; + +export const connectBitcoin = async ( + source: BitcoinSource, +): Promise => { + if (source === 'unisat') return connectUnisat(); + // v1 ships detection-only for Xverse / Leather; the wallet still appears in + // the connect modal so users see it, but signing/sending is deferred. + return stubAdapter(source); +}; diff --git a/src/wallet/index.ts b/src/wallet/index.ts new file mode 100644 index 0000000..13d163c --- /dev/null +++ b/src/wallet/index.ts @@ -0,0 +1,6 @@ +export { WalletProvider, useWallet } from './WalletProvider'; +export { default as ConnectWalletDialog } from './ConnectWalletDialog'; +export { default as SubstrateOptionalBanner } from './SubstrateOptionalBanner'; +export type { SubstrateConnection } from './substrate'; +export type { BitcoinConnection, BitcoinSource } from './bitcoin'; +export { claimSlash } from './substrate'; diff --git a/src/wallet/substrate.ts b/src/wallet/substrate.ts new file mode 100644 index 0000000..2f27cc0 --- /dev/null +++ b/src/wallet/substrate.ts @@ -0,0 +1,147 @@ +/** + * Substrate wallet adapter — wraps @polkadot/extension-dapp. + * + * Lazy-loaded: extension-dapp probes `window.injectedWeb3` and must NOT be + * imported at app bootstrap. Always call through `loadSubstrate*` helpers. + */ + +const DAPP_NAME = 'Allways'; + +export type SubstrateSource = + | 'polkadot-js' + | 'talisman' + | 'subwallet-js' + | string; + +export interface SubstrateAccount { + address: string; + name?: string; + source: SubstrateSource; +} + +export interface SubstrateConnection { + address: string; + source: SubstrateSource; + /** Sign an arbitrary UTF-8 message — returns hex signature. */ + signRaw: (message: string) => Promise; +} + +/** + * Detect Substrate extensions injected into `window.injectedWeb3`. + * Returns the source names without prompting the user. + */ +export const detectSubstrateExtensions = (): SubstrateSource[] => { + if (typeof window === 'undefined') return []; + const injected = ( + window as unknown as { injectedWeb3?: Record } + ).injectedWeb3; + if (!injected) return []; + return Object.keys(injected); +}; + +/** + * Enable extensions and read accounts. Returns the first account, since the + * v1 UX picks one wallet automatically. Caller can extend to a picker later. + */ +export const connectSubstrate = async (): Promise => { + const dapp = await import('@polkadot/extension-dapp'); + const extensions = await dapp.web3Enable(DAPP_NAME); + if (extensions.length === 0) { + throw new Error( + 'No Substrate extension found. Install Polkadot.js, Talisman, or SubWallet to continue.', + ); + } + + const accounts = await dapp.web3Accounts(); + if (accounts.length === 0) { + throw new Error( + 'No accounts available. Create an account in your Substrate extension and grant access to Allways.', + ); + } + + const acct = accounts[0]; + const source = acct.meta.source; + + return { + address: acct.address, + source, + signRaw: async (message: string): Promise => { + const injector = await dapp.web3FromSource(source); + if (!injector.signer?.signRaw) { + throw new Error(`${source} does not support raw signing`); + } + const { signature } = await injector.signer.signRaw({ + address: acct.address, + data: message, + type: 'bytes', + }); + return signature; + }, + }; +}; + +/** + * Build a connected @polkadot/api ApiPromise pointed at the configured WS + * endpoint. Used by the claim-slash flow for direct extrinsic signing. + */ +export const getSubstrateApi = async () => { + const ws = + (import.meta.env.VITE_SUBTENSOR_WS_URL as string | undefined) ?? + 'ws://localhost:9944'; + const { ApiPromise, WsProvider } = await import('@polkadot/api'); + const provider = new WsProvider(ws); + return ApiPromise.create({ provider }); +}; + +/** + * Submit a `claim_slash` extrinsic for the given swap. + * + * NOTE: The pallet path / method name may vary by chain spec — the call below + * targets `allways.claim_slash(swap_id)`. If your spec exposes the method + * under a different name, adjust here. + */ +export const claimSlash = async ( + conn: SubstrateConnection, + swapId: string, +): Promise => { + const dapp = await import('@polkadot/extension-dapp'); + const api = await getSubstrateApi(); + try { + const injector = await dapp.web3FromSource(conn.source); + const allwaysPallet = ( + api.tx as unknown as Record> + ).allways; + if (!allwaysPallet || !('claimSlash' in allwaysPallet)) { + throw new Error('claim_slash extrinsic not available on this chain'); + } + const tx = ( + allwaysPallet as unknown as { + claimSlash: (id: string) => { + signAndSend: (...args: unknown[]) => Promise; + }; + } + ).claimSlash(swapId); + + return await new Promise((resolve, reject) => { + tx.signAndSend( + conn.address, + { signer: injector.signer }, + (result: { + status: { + isInBlock: boolean; + isFinalized: boolean; + asInBlock?: { toString: () => string }; + }; + }) => { + if (result.status.isInBlock) { + resolve(result.status.asInBlock?.toString() ?? 'in-block'); + } else if (result.status.isFinalized) { + resolve('finalized'); + } + }, + ).catch(reject); + }); + } finally { + await api.disconnect(); + } +}; From e108acfa6809623aa378bde1dd323146b7ad42d8 Mon Sep 17 00:00:00 2001 From: Landyn Date: Mon, 11 May 2026 19:37:24 -0500 Subject: [PATCH 2/2] swap: drop wallet stubs, wire claim through contracts.call, guard TAO source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-repo review surfaced three problems in the browser-swap PR: 1. Xverse and Leather were shipped as `stubAdapter` that throws on every call. The connect dialog showed them as buttons with "(coming soon)". Better to ship Unisat as the BTC adapter and let v2 add others when the signing/sending surfaces actually exist. Removes ~30 lines of apologetic facade. 2. `claimSlash` targeted `api.tx.allways.claimSlash`, but `claim_slash` is an ink! contract selector — not a pallet extrinsic. Replace with `api.tx.contracts.call(VITE_CONTRACT_ADDRESS, 0, gas, null, data)` where `data` is the selector `0xcf3c3dd9` + u64-LE `swap_id`. Same path the validator uses server-side via `contract_client.exec_contract_raw`. Selector kept in sync with `allways/contract_client.py::CONTRACT_SELECTORS`. 3. `STORAGE_KEY = 'allways.pendingSwap'` was a single global slot — a second swap silently nuked the first. Key by (fromAddress, miner, blockAnchor) per spec §4c so parallel swaps coexist; the "pending" pointer is the most-recently-updated entry. 4. TAO-source swaps were gated only inside `sendFunds`, after the reservation had already broadcast on-chain — burning a miner slot. Add a quote-time guard in `SwapForm` so users hit the "use the CLI" message before paying that cost. Build clean; lint clean. --- .env.example | 5 ++ src/components/swap/ClaimSlashedButton.tsx | 6 +- src/components/swap/SwapForm.tsx | 18 +++++- src/env.d.ts | 1 + src/hooks/usePendingSwap.ts | 68 +++++++++++++++------ src/wallet/ConnectWalletDialog.tsx | 56 ++++++----------- src/wallet/WalletProvider.tsx | 12 ++-- src/wallet/bitcoin.ts | 63 ++++--------------- src/wallet/index.ts | 2 +- src/wallet/substrate.ts | 71 ++++++++++++++++++---- 10 files changed, 166 insertions(+), 136 deletions(-) diff --git a/.env.example b/.env.example index 2b927fc..98b4c78 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,8 @@ VITE_SWAP_API_URL=http://localhost:8000 # Optional: WS endpoint for direct subtensor calls (used by the in-browser # claim-slash flow). Defaults to the local dev subtensor. VITE_SUBTENSOR_WS_URL=ws://localhost:9944 + +# Ink! contract address — the browser claim flow targets this via +# `contracts.call(, …, )`. Mainnet default lives +# in `allways/constants.py::CONTRACT_ADDRESS`; override per environment. +VITE_CONTRACT_ADDRESS=5DjJmTpcHZvF3aZZEafKBdo3ksmdUSZ8bBBUSFhW3Ce3xf1J diff --git a/src/components/swap/ClaimSlashedButton.tsx b/src/components/swap/ClaimSlashedButton.tsx index ef34828..7eec4d5 100644 --- a/src/components/swap/ClaimSlashedButton.tsx +++ b/src/components/swap/ClaimSlashedButton.tsx @@ -11,9 +11,9 @@ interface Props { } /** - * Browser-side claim for slashed swaps. Signs the `allways.claim_slash` - * extrinsic directly via the connected Substrate wallet — swap-api is not - * involved (spec §5 / §9 "Slashed swap"). + * Browser-side claim for slashed swaps. Calls the ink! contract's + * `claim_slash(swap_id)` via `pallet_contracts::call` — same path the + * validator uses server-side. swap-api is not involved (spec §5 / §9). */ const ClaimSlashedButton: React.FC = ({ swapId, diff --git a/src/components/swap/SwapForm.tsx b/src/components/swap/SwapForm.tsx index 8183eb9..9b8c1d1 100644 --- a/src/components/swap/SwapForm.tsx +++ b/src/components/swap/SwapForm.tsx @@ -104,8 +104,14 @@ const SwapForm: React.FC = ({ onSubmit, disabled, onOpenConnect }) => { }, [autoToAddress, toAddress]); const sourceConnected = fromChain === 'btc' ? !!bitcoin : !!substrate; + // TAO-source send is not wired in the browser yet — broadcasting reserve + // here would burn a miner slot before the user hit the "use the CLI" + // dead-end further into the flow. Gate at quote time so they never get + // there. See useSwapFlow.sendFunds (TAO branch rejects). + const taoSourceBlocked = fromChain === 'tao'; const submitLabel = (() => { + if (taoSourceBlocked) return 'Use CLI for TAO → BTC (alw swap now)'; if (!sourceConnected) { return `Connect ${fromSpec.symbol} wallet`; } @@ -118,6 +124,7 @@ const SwapForm: React.FC = ({ onSubmit, disabled, onOpenConnect }) => { })(); const canSubmit = + !taoSourceBlocked && sourceConnected && !!toAddress && fromAmount > 0 && @@ -125,6 +132,7 @@ const SwapForm: React.FC = ({ onSubmit, disabled, onOpenConnect }) => { !disabled; const handleSubmit = () => { + if (taoSourceBlocked) return; if (!sourceConnected) { onOpenConnect(fromChain === 'tao'); return; @@ -271,17 +279,23 @@ const SwapForm: React.FC = ({ onSubmit, disabled, onOpenConnect }) => { - {best.isError && ( + {best.isError && !taoSourceBlocked && ( No miner is currently quoting {fromSpec.symbol} → {toSpec.symbol}. )} + {taoSourceBlocked && ( + + TAO → BTC isn’t supported in the browser yet. Run{' '} + alw swap now from the CLI for now. + + )} - ); - })} - + )} diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index c46fd13..495bdd8 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -5,11 +5,7 @@ import React, { useMemo, useState, } from 'react'; -import { - connectBitcoin, - type BitcoinConnection, - type BitcoinSource, -} from './bitcoin'; +import { connectBitcoin, type BitcoinConnection } from './bitcoin'; import { connectSubstrate, type SubstrateConnection } from './substrate'; interface WalletContextValue { @@ -18,7 +14,7 @@ interface WalletContextValue { /** True after the user has dismissed the "no Substrate wallet" banner. */ acknowledgedSubstrateOptional: boolean; connectSubstrateWallet: () => Promise; - connectBitcoinWallet: (source: BitcoinSource) => Promise; + connectBitcoinWallet: () => Promise; disconnect: () => void; acknowledgeSubstrateOptional: () => void; } @@ -38,8 +34,8 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ return conn; }, []); - const connectBitcoinWallet = useCallback(async (source: BitcoinSource) => { - const conn = await connectBitcoin(source); + const connectBitcoinWallet = useCallback(async () => { + const conn = await connectBitcoin(); setBitcoin(conn); return conn; }, []); diff --git a/src/wallet/bitcoin.ts b/src/wallet/bitcoin.ts index a46a4bb..32b66ca 100644 --- a/src/wallet/bitcoin.ts +++ b/src/wallet/bitcoin.ts @@ -1,12 +1,12 @@ /** - * Bitcoin wallet adapters — Unisat (full), Xverse / Leather (detection only). + * Bitcoin wallet adapter — Unisat only in v1. * - * Lazy: all `window.*` reads happen inside the connect helpers. Adapters are - * factored so a v2 PR can fill in Xverse / Leather signing without changing + * Lazy: all `window.*` reads happen inside the connect helper. The adapter + * factory exists so a future PR can add Xverse / Leather without touching * the WalletProvider contract. */ -export type BitcoinSource = 'unisat' | 'xverse' | 'leather'; +export type BitcoinSource = 'unisat'; export interface BitcoinConnection { address: string; @@ -33,64 +33,25 @@ const getUnisat = (): UnisatProvider | null => { return (window as unknown as { unisat?: UnisatProvider }).unisat ?? null; }; -const getXverseProviders = () => { - if (typeof window === 'undefined') return null; - return ( - (window as unknown as { XverseProviders?: unknown }).XverseProviders ?? null - ); -}; - -const getLeather = () => { - if (typeof window === 'undefined') return null; - return ( - (window as unknown as { LeatherProvider?: unknown }).LeatherProvider ?? null - ); -}; - export const detectBitcoinExtensions = (): BitcoinSource[] => { - const found: BitcoinSource[] = []; - if (getUnisat()) found.push('unisat'); - if (getXverseProviders()) found.push('xverse'); - if (getLeather()) found.push('leather'); - return found; + return getUnisat() ? ['unisat'] : []; }; -const connectUnisat = async (): Promise => { +export const connectBitcoin = async (): Promise => { const u = getUnisat(); - if (!u) throw new Error('Unisat extension not detected'); + if (!u) { + throw new Error( + 'Unisat extension not detected. Install Unisat to send BTC from your browser.', + ); + } // `requestAccounts()` triggers the unlock popup; `getAccounts()` returns [] // until the user approves the dapp the first time. const accounts = await u.requestAccounts(); if (accounts.length === 0) throw new Error('Unisat returned no accounts'); - const address = accounts[0]; return { - address, + address: accounts[0], source: 'unisat', signMessage: (msg) => u.signMessage(msg), sendBitcoin: (to, sats) => u.sendBitcoin(to, sats), }; }; - -const stubAdapter = (source: BitcoinSource): BitcoinConnection => { - const reject = (op: string) => () => - Promise.reject( - new Error( - `${source} support is stubbed in v1 — use Unisat for now (or finish the adapter to unblock ${op}).`, - ), - ); - return { - address: '', - source, - signMessage: reject('signMessage'), - sendBitcoin: reject('sendBitcoin'), - }; -}; - -export const connectBitcoin = async ( - source: BitcoinSource, -): Promise => { - if (source === 'unisat') return connectUnisat(); - // v1 ships detection-only for Xverse / Leather; the wallet still appears in - // the connect modal so users see it, but signing/sending is deferred. - return stubAdapter(source); -}; diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 13d163c..b8c84ee 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -2,5 +2,5 @@ export { WalletProvider, useWallet } from './WalletProvider'; export { default as ConnectWalletDialog } from './ConnectWalletDialog'; export { default as SubstrateOptionalBanner } from './SubstrateOptionalBanner'; export type { SubstrateConnection } from './substrate'; -export type { BitcoinConnection, BitcoinSource } from './bitcoin'; +export type { BitcoinConnection } from './bitcoin'; export { claimSlash } from './substrate'; diff --git a/src/wallet/substrate.ts b/src/wallet/substrate.ts index 2f27cc0..e6a9642 100644 --- a/src/wallet/substrate.ts +++ b/src/wallet/substrate.ts @@ -94,33 +94,71 @@ export const getSubstrateApi = async () => { }; /** - * Submit a `claim_slash` extrinsic for the given swap. + * Selector for the ink! contract's `claim_slash(swap_id: u64)` message. + * Keep in sync with `allways/contract_client.py::CONTRACT_SELECTORS`. + */ +const CLAIM_SLASH_SELECTOR = 'cf3c3dd9'; + +/** Default gas weight for a single contracts.call — generous, refunded if unused. */ +const DEFAULT_GAS = { + refTime: 5_000_000_000n, + proofSize: 800_000n, +}; + +const encodeClaimSlashInput = (swapId: bigint): `0x${string}` => { + const buf = new Uint8Array(8); + new DataView(buf.buffer).setBigUint64(0, swapId, /* littleEndian */ true); + const hex = Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); + return `0x${CLAIM_SLASH_SELECTOR}${hex}`; +}; + +/** + * Submit a `claim_slash` call to the Allways ink! contract. * - * NOTE: The pallet path / method name may vary by chain spec — the call below - * targets `allways.claim_slash(swap_id)`. If your spec exposes the method - * under a different name, adjust here. + * Goes through `pallet_contracts::call(dest, value=0, gas, storage=None, data)` + * — same path the validator's `contract_client.exec_contract_raw` uses + * server-side. The selector + u64-LE encoding matches `claim_slash` in + * `contract_client.py`; both must stay in sync. */ export const claimSlash = async ( conn: SubstrateConnection, - swapId: string, + swapId: string | number | bigint, ): Promise => { + const contractAddress = import.meta.env.VITE_CONTRACT_ADDRESS as + | string + | undefined; + if (!contractAddress) { + throw new Error( + 'VITE_CONTRACT_ADDRESS is not set — the browser claim flow needs the ink! contract address.', + ); + } const dapp = await import('@polkadot/extension-dapp'); const api = await getSubstrateApi(); try { const injector = await dapp.web3FromSource(conn.source); - const allwaysPallet = ( - api.tx as unknown as Record> - ).allways; - if (!allwaysPallet || !('claimSlash' in allwaysPallet)) { - throw new Error('claim_slash extrinsic not available on this chain'); + const contractsPallet = ( + api.tx as unknown as Record | undefined> + ).contracts; + if (!contractsPallet || !('call' in contractsPallet)) { + throw new Error( + 'pallet_contracts is not available on this chain — claim flow needs a runtime with contracts support.', + ); } + + const data = encodeClaimSlashInput(BigInt(swapId)); const tx = ( - allwaysPallet as unknown as { - claimSlash: (id: string) => { + contractsPallet as unknown as { + call: ( + dest: string, + value: number, + gasLimit: { refTime: bigint; proofSize: bigint }, + storageDepositLimit: null, + data: string, + ) => { signAndSend: (...args: unknown[]) => Promise; }; } - ).claimSlash(swapId); + ).call(contractAddress, 0, DEFAULT_GAS, null, data); return await new Promise((resolve, reject) => { tx.signAndSend( @@ -132,7 +170,14 @@ export const claimSlash = async ( isFinalized: boolean; asInBlock?: { toString: () => string }; }; + dispatchError?: { + toString: () => string; + }; }) => { + if (result.dispatchError) { + reject(new Error(result.dispatchError.toString())); + return; + } if (result.status.isInBlock) { resolve(result.status.asInBlock?.toString() ?? 'in-block'); } else if (result.status.isFinalized) {