diff --git a/docs/data-flow.md b/docs/data-flow.md index 57c0f1f0..eea919f5 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -139,6 +139,17 @@ Admin / Contract event | **Deletion** | Not possible for on-chain data. PostgreSQL mirror rows are not deleted | | **Who has access** | Public on-chain (permissionless read of Stellar ledger); API via Stellar RPC (`GET /api/v1/blockchain/user-bets/:address`); DB admin | +**Client-side contract writes (dispute, claim).** Not every on-chain write has +an API route. `file_dispute`, `cast_vote`, and `claim_winnings` are invoked +directly from the browser: the page builds the invocation with +`frontend/src/lib/wallet/soroban.ts`, the connected wallet signs the XDR, and +the signed transaction is submitted to Soroban RPC. Contract id and RPC +endpoint come from `NEXT_PUBLIC_CONTRACT_ID` / `NEXT_PUBLIC_SOROBAN_RPC_URL`; +when either is unset the pages render a "not configured" message instead of +failing. Reads for those pages still go through the API +(`GET /api/v1/blockchain/market/:id`, `GET /api/v1/blockchain/users/:user/bets`) +— there is deliberately no `/disputes` or per-bet HTTP route. + --- ### 6. Email Tracking & Delivery Events diff --git a/frontend/.env.example b/frontend/.env.example index 4bf73989..8d8c3244 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -18,3 +18,17 @@ NEXT_PUBLIC_TTS_API_URL=http://localhost:3002 # When unset or empty, the built-in PUBLIC/TESTNET/FUTURENET endpoints are used. # Example: https://horizon.my-org.internal NEXT_PUBLIC_HORIZON_URL_OVERRIDE= + +# Soroban contract configuration (optional) +# Required for on-chain write flows (filing a dispute, claiming winnings). +# When either is unset, those pages render a "not configured" message instead +# of submitting to a nonexistent backend route. +# Example: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 +NEXT_PUBLIC_CONTRACT_ID= +# Soroban RPC endpoint for the network the contract is deployed to. +# Example: https://soroban-testnet.stellar.org +NEXT_PUBLIC_SOROBAN_RPC_URL= +# Network passphrase the wallet should sign for. Defaults to the Stellar +# testnet passphrase when unset. +# Example: "Public Global Stellar Network ; September 2015" +NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE= diff --git a/frontend/README.md b/frontend/README.md index a5623346..96223157 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -9,6 +9,24 @@ This package contains the Handsoff web frontend. - [Design system](./docs/design-system.md) — design system guidelines and conventions for the frontend. - [`docs/`](./docs/) — additional frontend documentation. +## On-chain contract writes + +`file_dispute`, `cast_vote`, and `claim_winnings` have no API route, so the +pages that need them call the contract directly via +[`src/lib/wallet/soroban.ts`](./src/lib/wallet/soroban.ts): build → wallet-sign +→ submit to Soroban RPC. Configure it with: + +| Variable | Purpose | +|---|---| +| `NEXT_PUBLIC_CONTRACT_ID` | The PredictIQ contract id to invoke | +| `NEXT_PUBLIC_SOROBAN_RPC_URL` | Soroban RPC endpoint for the contract's network | +| `NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE` | Network passphrase the wallet signs for (defaults to testnet) | + +When `NEXT_PUBLIC_CONTRACT_ID` or `NEXT_PUBLIC_SOROBAN_RPC_URL` is unset, those +pages render a "not configured" message and disable the submit button rather +than calling a nonexistent endpoint. See `.env.example` for examples. + + ## Styling conventions (CSP-safe, no inline styles) ### Why: the CSP constraint diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a4d9e88a..25c6ac88 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "predictiq-frontend", "version": "1.0.0", "dependencies": { + "@stellar/stellar-sdk": "^12.3.0", "next": "16.2.11", "react": "19.2.8", "react-dom": "19.2.8", @@ -3215,6 +3216,91 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", + "integrity": "sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==", + "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", + "license": "Apache-2.0", + "dependencies": { + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.1.2", + "buffer": "^6.0.3", + "sha.js": "^2.3.6", + "tweetnacl": "^1.0.3" + }, + "optionalDependencies": { + "sodium-native": "^4.1.1" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.3.0.tgz", + "integrity": "sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^12.1.1", + "axios": "^1.7.7", + "bignumber.js": "^9.1.2", + "eventsource": "^2.0.2", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4849,6 +4935,25 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-addon-resolve": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.11.0.tgz", + "integrity": "sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, "node_modules/bare-events": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", @@ -4889,6 +4994,24 @@ } } }, + "node_modules/bare-module-resolve": { + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.6.tgz", + "integrity": "sha512-tsoOqHmtF6VmNX4LYDBZwvQVkSmEagnbykNjK31LMxz/amyKfjCs4K976n7uJd1bvWfxbpLQ3Fy7qq8an6jR6Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, "node_modules/bare-os": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", @@ -4909,6 +5032,13 @@ "bare-os": "^3.0.1" } }, + "node_modules/bare-semver": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", + "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/bare-stream": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", @@ -4946,6 +5076,35 @@ "bare-path": "^3.0.0" } }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", @@ -4983,6 +5142,15 @@ "node": ">= 18.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", @@ -5050,6 +5218,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -7197,6 +7389,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8058,6 +8259,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -12282,6 +12503,15 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -12445,6 +12675,19 @@ "dev": true, "license": "MIT" }, + "node_modules/require-addon": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.3.0.tgz", + "integrity": "sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -12605,6 +12848,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -12732,6 +12995,26 @@ "node": ">= 0.4" } }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -12968,6 +13251,16 @@ "node": ">= 14" } }, + "node_modules/sodium-native": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", + "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "require-addon": "^1.1.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -13640,6 +13933,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13653,6 +13960,12 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -13751,6 +14064,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -14019,6 +14338,12 @@ "dev": true, "license": "MIT" }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ff7028fe..85f8c75a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "bundlewatch": "bundlewatch" }, "dependencies": { + "@stellar/stellar-sdk": "^12.3.0", "next": "16.2.11", "react": "19.2.8", "react-dom": "19.2.8", @@ -73,4 +74,4 @@ "node": ">=20.0.0", "npm": ">=9.0.0" } -} \ No newline at end of file +} diff --git a/frontend/src/app/account/bets/[betId]/claim/page.tsx b/frontend/src/app/account/bets/[betId]/claim/page.tsx index 29880893..1e827c5a 100644 --- a/frontend/src/app/account/bets/[betId]/claim/page.tsx +++ b/frontend/src/app/account/bets/[betId]/claim/page.tsx @@ -1,22 +1,132 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; +import { useAsync } from '@/lib/hooks/useAsync'; +import { useTransaction } from '@/lib/hooks/useTransaction'; +import { describeWriteError } from '@/lib/api/contractErrors'; +import { api } from '@/lib/api/public-client'; +import { isSorobanConfigured, invokeContract } from '@/lib/wallet/soroban'; +import { useWallet } from '@/lib/wallet/WalletProvider'; -type Bet = { market_id: string | number; outcome?: string; winning?: boolean; status?: string; claimed?: boolean; payout?: string | number }; +interface Bet { + id?: string; + market_id: string | number; + outcome?: string; + winning?: boolean; + status?: string; + claimed?: boolean; + payout?: string | number; +} + +/** + * Load the bet from the connected wallet's bet history. + * + * There is no `/api/v1/blockchain/bets/{id}` route (#1756), so the bet is + * found within the typed client's `getUserBets` response for the connected + * address. Returns `null` when the bet is not in that address's history. + */ +async function findBet(address: string, betId: string, signal: AbortSignal): Promise { + const raw = (await api.getUserBets(address, undefined, signal)) as { bets?: Bet[] }; + const bets = raw.bets ?? []; + return bets.find((b) => String(b.id ?? '') === betId) ?? null; +} export default function ClaimPayoutPage() { const { betId } = useParams<{ betId: string }>(); - const [bet, setBet] = useState(null); - const [message, setMessage] = useState('Loading bet…'); - const [claiming, setClaiming] = useState(false); - useEffect(() => { fetch(`/api/v1/blockchain/bets/${encodeURIComponent(betId)}`).then(r => r.ok ? r.json() : Promise.reject()).then(setBet).catch(() => setMessage('Unable to load this bet.')); }, [betId]); + const wallet = useWallet(); + const tx = useTransaction<{ marketId: string }>(); + const [message, setMessage] = useState(null); + + const load = useCallback( + async (signal: AbortSignal): Promise => { + if (!wallet.address) return null; + return findBet(wallet.address, betId, signal); + }, + [wallet.address, betId] + ); + + const { + data: bet, + status, + error: loadError, + retry: reload, + } = useAsync(load, { immediate: false }); + + // The wallet address often arrives after first paint, so kick the load off + // once it is known (and again if the user switches accounts). + useEffect(() => { + if (wallet.address) void reload(); + }, [wallet.address, reload]); + + const claimError = + tx.status === 'failed' && tx.error ? describeWriteError(tx.error, 'claim') : null; + const onChainReady = isSorobanConfigured(); + + // `tx.status` is the reactive source of truth: surface success and refresh + // the bet once the transaction lands. Reading `tx.status` right after + // `await tx.run(...)` would see the pre-run value from this render's closure. + useEffect(() => { + if (tx.status === 'confirmed') { + setMessage('Payout confirmed.'); + void reload(); + } + }, [tx.status, reload]); + + const reason = !wallet.address + ? 'Connect your wallet to claim this payout.' + : status === 'loading' + ? 'Loading…' + : !bet + ? 'This bet was not found for the connected wallet.' + : bet.claimed || bet.status === 'claimed' + ? 'This bet has already been claimed.' + : bet.winning === false || bet.status === 'lost' + ? 'This position did not win.' + : ''; - const reason = !bet ? 'Loading…' : bet.claimed || bet.status === 'claimed' ? 'This bet has already been claimed.' : bet.winning === false || bet.status === 'lost' ? 'This position did not win.' : ''; async function claim() { - if (!bet || reason) return; - setClaiming(true); setMessage(''); - try { const res = await fetch(`/api/v1/blockchain/markets/${bet.market_id}/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bet_id: betId }) }); if (!res.ok) throw new Error(); const data = await res.json(); setBet({ ...bet, claimed: true }); setMessage(`Payout confirmed: ${data.amount ?? bet.payout ?? 'your winnings'}.`); } catch { setMessage('Claim failed. Please try again.'); } finally { setClaiming(false); } + if (!bet || reason || !wallet.address) return; + setMessage(null); + + await tx.run({ + sign: async () => ({ marketId: String(bet.market_id) }), + submit: async (signed) => + invokeContract( + wallet.address!, + { + method: 'claim_winnings', + args: [{ type: 'u64', value: signed.marketId }], + }, + wallet.signAndSubmit + ), + }); } - return

Claim payout

Bet: {betId}

{bet &&

Position: {bet.outcome ?? '—'}

}{reason &&

{reason}

}{message && !reason &&

{message}

}
; + + const busy = tx.status === 'pending' || tx.status === 'signed' || tx.status === 'submitted'; + + return ( +
+

Claim payout

+

Bet: {betId}

+ {bet &&

Position: {bet.outcome ?? '—'}

} + {reason &&

{reason}

} + {loadError &&

Unable to load this bet.

} + + {!onChainReady && ( +

+ On-chain submission is not configured for this deployment. Set{' '} + NEXT_PUBLIC_CONTRACT_ID and NEXT_PUBLIC_SOROBAN_RPC_URL to + enable claiming. +

+ )} + + {claimError &&

{claimError}

} + {message &&

{message}

} + + +
+ ); } diff --git a/frontend/src/app/markets/[marketId]/dispute/page.tsx b/frontend/src/app/markets/[marketId]/dispute/page.tsx index f90c7e24..9ea6a80a 100644 --- a/frontend/src/app/markets/[marketId]/dispute/page.tsx +++ b/frontend/src/app/markets/[marketId]/dispute/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useAsync } from '@/lib/hooks/useAsync'; import { useTransaction } from '@/lib/hooks/useTransaction'; import { describeWriteError } from '@/lib/api/contractErrors'; -import { ApiError } from '@/lib/api/public-client'; -import { getEnvConfig } from '@/lib/env'; +import { api } from '@/lib/api/public-client'; +import { isSorobanConfigured, invokeContract } from '@/lib/wallet/soroban'; +import { useWallet } from '@/lib/wallet/WalletProvider'; interface DisputedOutcome { outcome_index: number; @@ -20,18 +21,29 @@ interface DisputeStatusResponse { disputed_outcomes: DisputedOutcome[]; } -const config = getEnvConfig(); -const BASE_URL = config.NEXT_PUBLIC_API_URL.replace(/\/$/, ''); - -async function fetchDisputeStatus(marketId: string, signal: AbortSignal): Promise { - const res = await fetch(`${BASE_URL}/api/v1/blockchain/markets/${encodeURIComponent(marketId)}/disputes`, { - signal, - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new ApiError(body.message ?? `Failed to load dispute status (${res.status})`, res.status, body.code); - } - return res.json(); +/** + * Read the market's dispute state from the typed client. + * + * There is no `/api/v1/blockchain/markets/{id}/disputes` route in + * `services/api/openapi.yaml` (#1755), so the status is derived from the + * market view the backend does serve. The disputed-outcome list is optional + * on that view; when it is absent we render "no dispute filed yet" rather + * than fabricating an endpoint. + */ +async function fetchDisputeStatus( + marketId: string, + signal: AbortSignal +): Promise { + const raw = (await api.getBlockchainMarket(marketId, signal)) as { + id?: string | number; + is_disputed?: boolean; + disputed_outcomes?: DisputedOutcome[]; + }; + return { + market_id: String(raw.id ?? marketId), + is_disputed: raw.is_disputed ?? false, + disputed_outcomes: raw.disputed_outcomes ?? [], + }; } interface DisputePageProps { @@ -43,42 +55,59 @@ interface DisputePageProps { * currently disputed winner outcome — a dispute can resolve to more than * one winner (see contracts/predict-iq/src/test_disputes_winner_count.rs), * so this must render the full list, not just the first result. + * + * Filing goes through the contract's `file_dispute` entry point, signed by + * the connected wallet (#1755). When the deployment has no contract id / RPC + * configured, the button is disabled with a clear message instead of posting + * to a nonexistent backend route. */ export default function DisputePage({ params }: DisputePageProps) { const { marketId } = params; const [reason, setReason] = useState(''); - const tx = useTransaction<{ marketId: string; reason: string }>(); + const wallet = useWallet(); + const tx = useTransaction<{ marketId: string }>(); - const load = useCallback((signal: AbortSignal) => fetchDisputeStatus(marketId, signal), [marketId]); + const load = useCallback( + (signal: AbortSignal) => fetchDisputeStatus(marketId, signal), + [marketId] + ); const { data, status, error: loadError, retry: reload } = useAsync(load, { immediate: true }); const loading = status === 'loading'; - const filingError = tx.status === 'failed' && tx.error ? describeWriteError(tx.error, 'resolve') : null; + // Clear the draft reason once the dispute lands on-chain. + useEffect(() => { + if (tx.status === 'confirmed') setReason(''); + }, [tx.status]); + + const filingError = + tx.status === 'failed' && tx.error ? describeWriteError(tx.error, 'dispute') : null; + const onChainReady = isSorobanConfigured(); async function handleFileDispute() { + if (!wallet.address) { + await wallet.connect(); + return; + } + if (!onChainReady) return; + await tx.run({ - sign: async () => ({ marketId, reason }), - submit: async (signed) => { - const res = await fetch(`${BASE_URL}/api/v1/blockchain/markets/${encodeURIComponent(signed.marketId)}/disputes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reason: signed.reason }), - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new ApiError(body.message ?? `Failed to file dispute (${res.status})`, res.status, body.code, body.details); - } - const body = await res.json(); - return body.tx_hash as string; - }, + sign: async () => ({ marketId }), + submit: async () => + invokeContract( + wallet.address, + { method: 'file_dispute', args: [{ type: 'u64', value: marketId }] }, + wallet.signAndSubmit + ), }); - if (tx.status !== 'failed') { - setReason(''); - void reload(); - } + // Re-read the status regardless of the outcome: on success it reflects the + // new dispute, and on failure/rejection it simply confirms nothing changed. + // (`tx.status` here is the value captured before `run` resolved, so it must + // not be used to decide whether to reload.) + void reload(); } const disputedOutcomes = data?.disputed_outcomes ?? []; + const busy = tx.status === 'pending' || tx.status === 'signed' || tx.status === 'submitted'; return (
@@ -107,24 +136,33 @@ export default function DisputePage({ params }: DisputePageProps) {

Raise a dispute

+ + {!onChainReady && ( +

+ On-chain submission is not configured for this deployment. Set{' '} + NEXT_PUBLIC_CONTRACT_ID and NEXT_PUBLIC_SOROBAN_RPC_URL to + enable filing a dispute. +

+ )} +