diff --git a/.env.example b/.env.example index 7062504..98b4c78 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,15 @@ -# 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 + +# 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/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..9b8c1d1 --- /dev/null +++ b/src/components/swap/SwapForm.tsx @@ -0,0 +1,317 @@ +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; + // 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`; + } + 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 = + !taoSourceBlocked && + sourceConnected && + !!toAddress && + fromAmount > 0 && + !!best.data && + !disabled; + + const handleSubmit = () => { + if (taoSourceBlocked) return; + 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 && !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. + + )} + + + + ); +}; + +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..127d2a8 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,6 +1,10 @@ /// interface ImportMetaEnv { readonly VITE_REACT_APP_BASE_URL: string; + readonly VITE_SWAP_API_URL?: string; + readonly VITE_SUBTENSOR_WS_URL?: string; + readonly VITE_CONTRACT_ADDRESS?: 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..dcf15b9 --- /dev/null +++ b/src/hooks/usePendingSwap.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** + * Mirror of the CLI's `pending_swap.json`. Spec §4c: per-user disambiguation + * is keyed on (fromAddress, miner, blockAnchor) — two parallel swaps must not + * clobber each other in localStorage. + */ +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 — used as recency tiebreaker for `pending`. */ + updatedAt: string; +} + +const STORAGE_KEY = 'allways.pendingSwaps'; + +const swapKey = ( + s: Pick, +): string => `${s.fromAddress}|${s.minerHotkey}|${s.blockAnchor}`; + +const readAll = (): Record => { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : {}; + } catch { + return {}; + } +}; + +const writeAll = (value: Record): void => { + if (typeof window === 'undefined') return; + try { + if (Object.keys(value).length === 0) + window.localStorage.removeItem(STORAGE_KEY); + else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + /* quota / private-mode — silently ignore */ + } +}; + +const mostRecent = ( + entries: Record, +): PendingSwap | null => { + const list = Object.values(entries); + if (list.length === 0) return null; + return list.reduce((a, b) => (a.updatedAt > b.updatedAt ? a : b)); +}; + +export const usePendingSwap = () => { + const [pending, setPending] = useState(() => + mostRecent(readAll()), + ); + + // Keep tabs roughly in sync. 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(mostRecent(readAll())); + }; + window.addEventListener('storage', handler); + return () => window.removeEventListener('storage', handler); + }, []); + + const save = useCallback((value: Omit) => { + const next: PendingSwap = { ...value, updatedAt: new Date().toISOString() }; + const all = readAll(); + all[swapKey(next)] = next; + writeAll(all); + setPending(next); + }, []); + + const merge = useCallback((patch: Partial) => { + setPending((prev) => { + if (!prev) return prev; + const next: PendingSwap = { + ...prev, + ...patch, + updatedAt: new Date().toISOString(), + }; + const all = readAll(); + all[swapKey(next)] = next; + writeAll(all); + return next; + }); + }, []); + + const clear = useCallback(() => { + setPending((prev) => { + if (!prev) { + writeAll({}); + return null; + } + const all = readAll(); + delete all[swapKey(prev)]; + writeAll(all); + return mostRecent(all); + }); + }, []); + + 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..c976729 --- /dev/null +++ b/src/wallet/ConnectWalletDialog.tsx @@ -0,0 +1,201 @@ +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 } 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 ConnectWalletDialog: React.FC = ({ + open, + onClose, + requireSubstrate = false, +}) => { + const { substrate, bitcoin, connectSubstrateWallet, connectBitcoinWallet } = + useWallet(); + const [substrateExtensions, setSubstrateExtensions] = useState([]); + const [unisatDetected, setUnisatDetected] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open) { + setSubstrateExtensions(detectSubstrateExtensions()); + setUnisatDetected(detectBitcoinExtensions().length > 0); + 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 () => { + setBusy(true); + setError(null); + try { + await connectBitcoinWallet(); + } 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: Unisat — {bitcoin.address.slice(0, 8)}… + {bitcoin.address.slice(-6)} + + ) : !unisatDetected ? ( + + Unisat extension not detected. Install Unisat to send BTC from + your browser. + + ) : ( + + )} + + + {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..495bdd8 --- /dev/null +++ b/src/wallet/WalletProvider.tsx @@ -0,0 +1,82 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { connectBitcoin, type BitcoinConnection } 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: () => 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 () => { + const conn = await connectBitcoin(); + 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..32b66ca --- /dev/null +++ b/src/wallet/bitcoin.ts @@ -0,0 +1,57 @@ +/** + * Bitcoin wallet adapter — Unisat only in v1. + * + * 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'; + +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; +}; + +export const detectBitcoinExtensions = (): BitcoinSource[] => { + return getUnisat() ? ['unisat'] : []; +}; + +export const connectBitcoin = async (): Promise => { + const u = getUnisat(); + 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'); + return { + address: accounts[0], + source: 'unisat', + signMessage: (msg) => u.signMessage(msg), + sendBitcoin: (to, sats) => u.sendBitcoin(to, sats), + }; +}; diff --git a/src/wallet/index.ts b/src/wallet/index.ts new file mode 100644 index 0000000..b8c84ee --- /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 } from './bitcoin'; +export { claimSlash } from './substrate'; diff --git a/src/wallet/substrate.ts b/src/wallet/substrate.ts new file mode 100644 index 0000000..e6a9642 --- /dev/null +++ b/src/wallet/substrate.ts @@ -0,0 +1,192 @@ +/** + * 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 }); +}; + +/** + * 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. + * + * 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 | 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 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 = ( + contractsPallet as unknown as { + call: ( + dest: string, + value: number, + gasLimit: { refTime: bigint; proofSize: bigint }, + storageDepositLimit: null, + data: string, + ) => { + signAndSend: (...args: unknown[]) => Promise; + }; + } + ).call(contractAddress, 0, DEFAULT_GAS, null, data); + + return await new Promise((resolve, reject) => { + tx.signAndSend( + conn.address, + { signer: injector.signer }, + (result: { + status: { + isInBlock: boolean; + 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) { + resolve('finalized'); + } + }, + ).catch(reject); + }); + } finally { + await api.disconnect(); + } +};