From a9e910bfd7438f1006cf0b10664832c122722d7b Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:04:35 -0400 Subject: [PATCH] fix: GuessTheNumber error handling; submission in react MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #285 - Add `e2e/tests/guess-the-number.spec.ts` to verify that the page works as expected in both templates - Add `e2e/util` with fake Freighter and stub RPC - Run this spec in CI - React: added `name="guess"` to the input. - Both templates: failures from the `catch` block and from `isErr()` now go to a separate error state that shows “Couldn’t submit your guess: …” and leaves Submit enabled. - Shared helper: `errorMessage()` in `app-lib/format.ts` formats both `Errors` and the plain `{code, message}` objects that wallets reject with. - Beyond the issue: added `required` to the input in both templates. Without it, an empty submit still shows “Incorrect guess.” --- .github/workflows/build.yml | 3 + app-lib/format.ts | 18 ++ app-lib/index.ts | 2 +- e2e/package.json | 3 +- e2e/tests/guess-the-number.spec.ts | 122 +++++++++++ e2e/util/fake-freighter.ts | 104 +++++++++ e2e/util/stub-rpc.ts | 199 ++++++++++++++++++ package-lock.json | 129 +++++++++++- .../react/src/components/GuessTheNumber.tsx | 24 ++- .../src/lib/components/GuessTheNumber.svelte | 25 ++- 10 files changed, 618 insertions(+), 11 deletions(-) create mode 100644 e2e/tests/guess-the-number.spec.ts create mode 100644 e2e/util/fake-freighter.ts create mode 100644 e2e/util/stub-rpc.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3dcbf8e..d99f8115 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -191,3 +191,6 @@ jobs: - name: Run cross-framework parity smoke tests run: npx playwright test tests/smoke.spec.ts working-directory: e2e + - name: Run Guess the Number tests + run: npx playwright test tests/guess-the-number.spec.ts + working-directory: e2e diff --git a/app-lib/format.ts b/app-lib/format.ts index 8d3ddd95..37dd60ea 100644 --- a/app-lib/format.ts +++ b/app-lib/format.ts @@ -72,3 +72,21 @@ export function networkStatus( } return { appNetwork, walletNetwork, state: "ok", title: "" } } + +/** + * A short, readable message for anything a wallet, RPC or contract call threw. + * Wallets reject with plain `{ code, message }` objects rather than `Error`s, + * and SDK errors can carry a long event log after the first line, so this + * keeps only the first line of whichever message it finds. + */ +export const errorMessage = (error: unknown): string => { + const message = + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : String(error) + const [firstLine = ""] = message.split("\n") + return firstLine.trim() || "Unknown error" +} diff --git a/app-lib/index.ts b/app-lib/index.ts index 519d4932..9f05c273 100644 --- a/app-lib/index.ts +++ b/app-lib/index.ts @@ -4,7 +4,7 @@ // stores, UI components) stays in each template. export * from "./env" // rpcUrl, networkPassphrase, allowHttp, stellarNetwork, network, labPrefix -export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState +export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState, errorMessage export * from "./friendbot" // getFriendbotUrl export * from "./subscription" // subscribeToEvents export * from "./wallet" // connectWallet, disconnectWallet, profileModal, signTransaction, onWalletChange, fetchBalances, wallet, MappedBalances, WalletState diff --git a/e2e/package.json b/e2e/package.json index 757974c2..e668a6a5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -8,6 +8,7 @@ "test": "playwright test" }, "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "@stellar/stellar-sdk": "^16.4.1" } } diff --git a/e2e/tests/guess-the-number.spec.ts b/e2e/tests/guess-the-number.spec.ts new file mode 100644 index 00000000..a43f0927 --- /dev/null +++ b/e2e/tests/guess-the-number.spec.ts @@ -0,0 +1,122 @@ +import { test, expect, type Page } from "@playwright/test" +import { Keypair, xdr } from "@stellar/stellar-sdk" +import { connectFakeFreighter, DECLINED } from "../util/fake-freighter" +import { CONTRACT_ERROR, stubRpc } from "../util/stub-rpc" + +// Guess the Number, from typing a guess to the result the player sees. The +// wallet and the RPC are stand-ins (see `../util`), so these run the same +// against every target and network with no chain, extension or funded account. +// +// Regression guard for stellar-scaffold/ui#285: a guess must reach the +// contract call and the wallet, the contract's answer must reach the player, +// and a failure along the way (declined signature, simulation or contract +// error) must not read as a wrong guess. + +const guesser = Keypair.random() +const INCORRECT = "Incorrect guess. Try again!" +const CORRECT = "You got it!" +const COULD_NOT_SUBMIT = /couldn't submit your guess/i + +test("a wrong guess is signed, sent, and reported as incorrect", async ({ + page, + baseURL, +}) => { + const wallet = await connectFakeFreighter(page, guesser, { + onSign: "approve", + }) + const rpc = await stubRpc(page, baseURL!, { + account: guesser.publicKey(), + simulation: "succeeds", + returns: false, + }) + + await guess(page, "7") + + await expect(page.getByText(INCORRECT)).toBeVisible() + expect(wallet.signRequests.map(guessIn)).toEqual([7n]) + expect(rpc.sent.map(guessIn)).toEqual([7n]) + await expect(page.getByText(COULD_NOT_SUBMIT)).toBeHidden() + await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled() +}) + +test("a right guess is signed, sent, and reported as a win", async ({ + page, + baseURL, +}) => { + await connectFakeFreighter(page, guesser, { onSign: "approve" }) + const rpc = await stubRpc(page, baseURL!, { + account: guesser.publicKey(), + simulation: "succeeds", + returns: true, + }) + + await guess(page, "7") + + await expect(page.getByText(CORRECT)).toBeVisible() + expect(rpc.sent.map(guessIn)).toEqual([7n]) + await expect(page.getByText(INCORRECT)).toBeHidden() +}) + +test("a declined signature is reported as an error, not a wrong guess", async ({ + page, + baseURL, +}) => { + const wallet = await connectFakeFreighter(page, guesser, { + onSign: "decline", + }) + const rpc = await stubRpc(page, baseURL!, { + account: guesser.publicKey(), + simulation: "succeeds", + }) + + await guess(page, "7") + + await expect(page.getByText(COULD_NOT_SUBMIT)).toContainText(DECLINED) + expect(wallet.signRequests.map(guessIn)).toEqual([7n]) + expect(rpc.sent).toHaveLength(0) + await expect(page.getByText(INCORRECT)).toBeHidden() + await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled() +}) + +test("a failed simulation is reported as an error, not a wrong guess", async ({ + page, + baseURL, +}) => { + const wallet = await connectFakeFreighter(page, guesser, { + onSign: "approve", + }) + const rpc = await stubRpc(page, baseURL!, { + account: guesser.publicKey(), + simulation: "fails", + }) + + await guess(page, "7") + + await expect(page.getByText(COULD_NOT_SUBMIT)).toContainText(CONTRACT_ERROR) + expect(rpc.simulated.map(guessIn)).toEqual([7n]) + expect(wallet.signRequests).toHaveLength(0) + await expect(page.getByText(INCORRECT)).toBeHidden() + await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled() +}) + +/** Load the app, wait for the stand-in wallet to connect, and submit a guess. */ +async function guess(page: Page, value: string) { + await page.goto("/") + const address = guesser.publicKey() + const short = `${address.slice(0, 4)}...${address.slice(-4)}` + await expect(page.getByRole("button", { name: short })).toBeVisible() + + await page.getByPlaceholder("Guess a number from 1 to 10!").fill(value) + await page.getByRole("button", { name: "Submit" }).click() +} + +/** The number passed to `guess` in a base64 transaction envelope. */ +function guessIn(envelope: string): bigint { + const [op] = xdr.TransactionEnvelope.fromXDR(envelope, "base64") + .v1() + .tx() + .operations() + const call = op.body().invokeHostFunctionOp().hostFunction().invokeContract() + expect(call.functionName().toString()).toBe("guess") + return call.args()[0].u64().toBigInt() +} diff --git a/e2e/util/fake-freighter.ts b/e2e/util/fake-freighter.ts new file mode 100644 index 00000000..cfff81d9 --- /dev/null +++ b/e2e/util/fake-freighter.ts @@ -0,0 +1,104 @@ +import type { Page } from "@playwright/test" +import type { Keypair } from "@stellar/stellar-sdk" +import { KeypairSigner } from "@stellar/stellar-sdk/contract" + +export interface FakeFreighterOptions { + /** + * What the player does when the app asks for a signature: `"approve"` signs + * with the keypair, `"decline"` rejects as if they clicked Reject. + */ + onSign: "approve" | "decline" +} + +/** The message Freighter rejects with when the player declines to sign. */ +export const DECLINED = "User declined access" + +/** + * Stand in for the Freighter extension, connected as `keypair`. + * + * Wallets Kit restores the last-used wallet and address from localStorage, and + * `@stellar/freighter-api` talks to the extension over `window.postMessage`, so + * an init script can play the extension's side of that conversation with no + * change to the app. Signing happens here in Node, with the SDK's + * `KeypairSigner`, so the secret key never enters the page. + * + * Returns `signRequests`: every envelope the app asked to have signed, as sent + * (unsigned), whether it was approved or declined. + */ +export async function connectFakeFreighter( + page: Page, + keypair: Keypair, + { onSign }: FakeFreighterOptions, +) { + const signRequests: string[] = [] + + await page.exposeFunction( + "__fakeFreighterSign", + async (transactionXdr: string, networkPassphrase: string) => { + signRequests.push(transactionXdr) + if (onSign === "decline") { + return { apiError: { code: -4, message: DECLINED } } + } + try { + const { signedTxXdr } = await new KeypairSigner( + keypair, + networkPassphrase, + ).signTransaction(transactionXdr) + return { + signedTransaction: signedTxXdr, + signerAddress: keypair.publicKey(), + } + } catch (e) { + // Reply rather than throw, so the app sees a wallet error instead + // of waiting forever for an answer + return { apiError: { code: -1, message: `Fake Freighter: ${e}` } } + } + }, + ) + + await page.addInitScript((address) => { + type Sign = (xdr: string, passphrase: string) => Promise + const sign = (window as unknown as { __fakeFreighterSign: Sign }) + .__fakeFreighterSign + + localStorage.setItem("@StellarWalletsKit/selectedModuleId", "freighter") + localStorage.setItem("@StellarWalletsKit/activeAddress", address) + Object.assign(window, { freighter: true }) + + window.addEventListener("message", (event) => { + const req = event.data + if ( + event.source !== window || + req?.source !== "FREIGHTER_EXTERNAL_MSG_REQUEST" + ) { + return + } + const reply = (payload: object) => + window.postMessage( + { + source: "FREIGHTER_EXTERNAL_MSG_RESPONSE", + messagedId: req.messageId, // sic, freighter-api's spelling + ...payload, + }, + window.location.origin, + ) + + switch (req.type) { + case "REQUEST_CONNECTION_STATUS": + return reply({ isConnected: true }) + case "SUBMIT_TRANSACTION": + return void sign(req.transactionXdr, req.networkPassphrase).then( + reply, + ) + default: + // e.g. REQUEST_NETWORK: the app then treats the network as + // unverified, like most wallets that don't report one + return reply({ + apiError: { code: -1, message: `Not supported: ${req.type}` }, + }) + } + }) + }, keypair.publicKey()) + + return { signRequests } +} diff --git a/e2e/util/stub-rpc.ts b/e2e/util/stub-rpc.ts new file mode 100644 index 00000000..cf391aab --- /dev/null +++ b/e2e/util/stub-rpc.ts @@ -0,0 +1,199 @@ +import { createHash } from "node:crypto" +import type { Page } from "@playwright/test" +import { Keypair, SorobanDataBuilder, xdr } from "@stellar/stellar-sdk" + +export interface StubRpcOptions { + /** The funded account the app is connected as (a `G…` address). */ + account: string + /** Whether simulating a contract call succeeds or fails with a contract error. */ + simulation: "succeeds" | "fails" + /** What a contract call returns, in simulation and once it lands. */ + returns?: boolean +} + +/** The error a failing simulation reports. */ +export const CONTRACT_ERROR = "HostError: Error(Contract, #3)" + +/** + * Answer the app's Stellar RPC calls with a minimal, consistent network: + * + * - `account` exists and is funded; no other ledger entry does. + * - Simulating a transaction either fails with {@link CONTRACT_ERROR}, or + * succeeds returning `returns`, with a write in its footprint so the SDK + * treats it as a call that needs signing and sending. + * - A sent transaction lands immediately, successfully, returning `returns`. + * + * Requests are matched by their JSON-RPC body on anything that leaves the + * app's own origin, so this works whichever network the clients were built + * for. Returns the envelopes it was asked to simulate and to send, so tests + * can check what the app actually submitted. + */ +export async function stubRpc( + page: Page, + baseURL: string, + { account, simulation, returns = false }: StubRpcOptions, +) { + const simulated: string[] = [] + const sent: string[] = [] + const byHash = new Map() + + const latestLedger = 1000 + const closeTime = String(Math.floor(Date.now() / 1000)) + const ledgerInfo = { + latestLedger, + latestLedgerCloseTime: closeTime, + oldestLedger: 1, + oldestLedgerCloseTime: closeTime, + } + + const accountId = Keypair.fromPublicKey(account).xdrPublicKey() + const accountKey = xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ accountId }), + ) + const accountEntry = xdr.LedgerEntryData.account( + new xdr.AccountEntry({ + accountId, + balance: xdr.Int64.fromString("100000000000"), + seqNum: xdr.Int64.fromString("1"), + numSubEntries: 0, + inflationDest: null, + flags: 0, + homeDomain: "", + thresholds: Buffer.from([1, 0, 0, 0]), + signers: [], + ext: new xdr.AccountEntryExt(0), + }), + ) + + const returnValue = xdr.ScVal.scvBool(returns) + + // What the network reports for a landed contract call. The SDK reads the + // call's result from `sorobanMeta.returnValue`; the rest only has to decode. + const landedResult = new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString("100"), + result: xdr.TransactionResultResult.txSuccess([ + xdr.OperationResult.opInner( + xdr.OperationResultTr.invokeHostFunction( + xdr.InvokeHostFunctionResult.invokeHostFunctionSuccess( + Buffer.alloc(32), + ), + ), + ), + ]), + ext: new xdr.TransactionResultExt(0), + }) + const landedMeta = new xdr.TransactionMeta( + 3, + new xdr.TransactionMetaV3({ + ext: new xdr.ExtensionPoint(0), + txChangesBefore: [], + operations: [], + txChangesAfter: [], + sorobanMeta: new xdr.SorobanTransactionMeta({ + ext: new xdr.SorobanTransactionMetaExt(0), + events: [], + returnValue, + diagnosticEvents: [], + }), + }), + ) + + const results: Record object> = { + getLatestLedger: () => ({ + id: "0".repeat(64), + protocolVersion: 23, + sequence: latestLedger, + }), + getLedgerEntries: ({ keys }: { keys: string[] }) => ({ + latestLedger, + entries: keys + .filter((key) => key === accountKey.toXDR("base64")) + .map((key) => ({ + key, + xdr: accountEntry.toXDR("base64"), + lastModifiedLedgerSeq: latestLedger, + })), + }), + simulateTransaction: ({ transaction }: { transaction: string }) => { + simulated.push(transaction) + if (simulation === "fails") { + return { latestLedger, error: CONTRACT_ERROR } + } + return { + latestLedger, + minResourceFee: "100", + transactionData: new SorobanDataBuilder() + .setReadWrite([accountKey]) + .build() + .toXDR("base64"), + results: [{ auth: [], xdr: returnValue.toXDR("base64") }], + } + }, + sendTransaction: ({ transaction }: { transaction: string }) => { + sent.push(transaction) + const hash = createHash("sha256").update(transaction).digest("hex") + byHash.set(hash, transaction) + return { + status: "PENDING", + hash, + latestLedger, + latestLedgerCloseTime: closeTime, + } + }, + getTransaction: ({ hash }: { hash: string }) => { + const envelopeXdr = byHash.get(hash) + if (!envelopeXdr) return { status: "NOT_FOUND", ...ledgerInfo } + return { + status: "SUCCESS", + ...ledgerInfo, + ledger: latestLedger, + createdAt: closeTime, + applicationOrder: 1, + feeBump: false, + envelopeXdr, + resultXdr: landedResult.toXDR("base64"), + resultMetaXdr: landedMeta.toXDR("base64"), + } + }, + } + + const cors = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "POST, OPTIONS", + } + const appOrigin = new URL(baseURL).origin + + await page.route( + (url) => url.origin !== appOrigin, + async (route) => { + const request = route.request() + if (request.method() === "OPTIONS") { + return route.fulfill({ status: 204, headers: cors }) + } + let body: { + jsonrpc?: string + id?: unknown + method?: string + params?: unknown + } + try { + body = request.postDataJSON() ?? {} + } catch { + body = {} + } + if (body.jsonrpc !== "2.0") return route.fallback() + + const answer = body.method ? results[body.method] : undefined + const response = answer + ? { result: answer(body.params) } + : { error: { code: -32601, message: `Not stubbed: ${body.method}` } } + return route.fulfill({ + headers: cors, + json: { jsonrpc: "2.0", id: body.id, ...response }, + }) + }, + ) + + return { simulated, sent } +} diff --git a/package-lock.json b/package-lock.json index 14fb15cd..ade9d04d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,16 @@ "node": ">=20.0.0" } }, + "app-lib/clients/fungible-allowlist": { + "version": "0.0.0", + "dependencies": { + "@stellar/stellar-sdk": "^16.0.1", + "buffer": "6.0.3" + }, + "devDependencies": { + "typescript": "^5.6.2" + } + }, "app-lib/clients/guess_the_number": { "version": "0.0.0", "dependencies": { @@ -91,6 +101,16 @@ "node": ">=20.0.0" } }, + "app-lib/clients/guess-the-number": { + "version": "0.0.0", + "dependencies": { + "@stellar/stellar-sdk": "^16.0.1", + "buffer": "6.0.3" + }, + "devDependencies": { + "typescript": "^5.6.2" + } + }, "app-lib/clients/nft_enumerable_example": { "version": "0.0.0", "dependencies": { @@ -124,6 +144,16 @@ "node": ">=20.0.0" } }, + "app-lib/clients/nft-enumerable": { + "version": "0.0.0", + "dependencies": { + "@stellar/stellar-sdk": "^16.0.1", + "buffer": "6.0.3" + }, + "devDependencies": { + "typescript": "^5.6.2" + } + }, "app-lib/node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -238,7 +268,92 @@ "name": "@stellar-scaffold/ui-e2e", "version": "0.0.0", "devDependencies": { - "@playwright/test": "^1.49.0" + "@playwright/test": "^1.49.0", + "@stellar/stellar-sdk": "^16.4.1" + } + }, + "e2e/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "e2e/node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } + }, + "e2e/node_modules/@stellar/stellar-sdk": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.4.1.tgz", + "integrity": "sha512-0kT+ShOUE6dc0smLEsajuizss1hvlGDsCooM88FqgxNOCKpY8IuQPzCs75RNKMSMITz+dX7KZ5qGhQ2EWGMTTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.20.0", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.4", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "e2e/node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "dev": true, + "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" + } + }, + "e2e/node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "dev": true, + "license": "MIT" + }, + "e2e/node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -11090,6 +11205,10 @@ "resolved": "app-lib/clients/fungible_allowlist_example", "link": true }, + "node_modules/fungible-allowlist": { + "resolved": "app-lib/clients/fungible-allowlist", + "link": true + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -11238,6 +11357,10 @@ "resolved": "app-lib/clients/guess_the_number", "link": true }, + "node_modules/guess-the-number": { + "resolved": "app-lib/clients/guess-the-number", + "link": true + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -12631,6 +12754,10 @@ "resolved": "app-lib/clients/nft_enumerable_example", "link": true }, + "node_modules/nft-enumerable": { + "resolved": "app-lib/clients/nft-enumerable", + "link": true + }, "node_modules/node-addon-api": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", diff --git a/templates/react/src/components/GuessTheNumber.tsx b/templates/react/src/components/GuessTheNumber.tsx index 095130d1..2b556766 100644 --- a/templates/react/src/components/GuessTheNumber.tsx +++ b/templates/react/src/components/GuessTheNumber.tsx @@ -1,3 +1,4 @@ +import { errorMessage } from "@stellar-scaffold/app-lib" import { guessTheNumber } from "@stellar-scaffold/app-lib/clients" import { useState } from "react" import { useWallet } from "../hooks/useWallet" @@ -5,8 +6,17 @@ import { useWallet } from "../hooks/useWallet" export const GuessTheNumber = () => { const { address, updateBalances, signTransaction } = useWallet() const [result, setResult] = useState< - "idle" | "loading" | "success" | "failure" + "idle" | "loading" | "success" | "failure" | "error" >("idle") + const [error, setError] = useState() + + // Couldn't get an answer from the contract (rejected signature, network, + // simulation or contract error). Not the same as a wrong guess. + const fail = (e: unknown) => { + console.error(e) + setError(errorMessage(e)) + setResult("error") + } const submitGuess = async (formData: FormData) => { if (!address) { @@ -31,14 +41,13 @@ export const GuessTheNumber = () => { const { result } = await tx.signAndSend({ signTransaction }) if (result.isErr()) { - console.error(result.unwrapErr()) + fail(result.unwrapErr()) } else { setResult(result.unwrap() ? "success" : "failure") await updateBalances() } } catch (e) { - console.error(e) - setResult("failure") + fail(e) } } @@ -50,9 +59,11 @@ export const GuessTheNumber = () => {