diff --git a/AGENTS.md b/AGENTS.md index 4a068d8..c5dedfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ This repository is a pnpm workspace for the OMS TypeScript SDK. The root package ## Commands - `pnpm install --frozen-lockfile`: Install dependencies in CI-compatible mode. -- `pnpm check:package-versions`: Verify publishable workspace package versions and SDK peer dependencies stay in sync. +- `pnpm check:package-versions`: Verify publishable workspace package versions match and connector SDK references use `workspace:*`. - `pnpm exec tsc --noEmit`: Typecheck SDK source. - `pnpm test`: Run Vitest and type tests. - `pnpm test:types`: Compile `type-tests/oidcProviderTypes.ts`; useful for public type/API changes. @@ -145,10 +145,18 @@ execution commands. - Bug fixes should include regression evidence when feasible. - For auth, signing, transaction execution, access revocation, storage persistence, and error classification, add focused tests that would fail if the externally visible promise breaks. +### Public Error Contract Tests + +- Follow the detailed rules in `TESTING.md` before adding or updating public error contract tests. +- Exercise real public runtime APIs and mock only external boundaries. +- Snapshot stable public fields only; do not snapshot raw `cause`, stacks, generated internals, headers, timestamps, or full backend payloads. +- Snapshot changes are not automatically regressions. First decide whether the new error shape is the intended public contract, then either update the snapshot or fix the implementation. + ## Generated Files and External Artifacts - `src/generated/waas.gen.ts` is generated by Webrpc and marked `DO NOT EDIT`. Update the generated-client source of truth rather than hand-editing this file as normal source. - The generated WaaS header references `schema/waas.ridl`; if regenerating the client, document the schema source and command used. +- The wagmi connector's SDK peer dependency is intentionally `workspace:*` in source. Release with pnpm so the published package gets the exact SDK version; do not hand-edit that peer to a literal version. - `pnpm-lock.yaml` is the dependency lockfile. Update it through pnpm, not by hand. - `dist/`, `examples/react/dist/`, `examples/wagmi/dist/`, and `*.tsbuildinfo` files are build outputs and should not be edited as source. @@ -188,6 +196,7 @@ execution commands. | Test commands (`package.json` scripts) | `TESTING.md`, `.github/workflows/tests.yml`, `AGENTS.md` Commands section | | Node or pnpm version | `.nvmrc`, `package.json#packageManager`, `.github/workflows/*.yml` | | New third-party dependency | `package.json`, `pnpm-lock.yaml`, context7 instruction in `AGENTS.md` | +| Publishable package versioning or workspace peer protocol | `PUBLISHING.md`, `scripts/check-package-versions.cjs`, `pnpm-lock.yaml` | | `src/generated/waas.gen.ts` (regenerated) | Document schema source + regen command in PR description | | Repo structure (new top-level dirs) | `AGENTS.md` Repository Layout section | | Examples added or renamed | `pnpm-workspace.yaml`, root `package.json` scripts, `pages.yml` | diff --git a/API.md b/API.md index 5badc36..382be2d 100644 --- a/API.md +++ b/API.md @@ -774,10 +774,21 @@ class OmsSdkError extends Error { status?: number txnId?: string retryable?: boolean + upstreamError?: OmsUpstreamError cause?: unknown } ``` +```typescript +interface OmsUpstreamError { + service: 'waas' | 'indexer' + name?: string + code?: number | string + message?: string + status?: number +} +``` + ```typescript type OmsSdkErrorCode = | 'OMS_HTTP_ERROR' @@ -789,18 +800,27 @@ type OmsSdkErrorCode = | 'OMS_WALLET_SELECTION_STALE' | 'OMS_WALLET_SELECTION_UNAVAILABLE' | 'OMS_WALLET_SELECTION_IN_FLIGHT' + | 'OMS_TRANSACTION_EXECUTION_UNCONFIRMED' | 'OMS_TRANSACTION_STATUS_LOOKUP_FAILED' | 'OMS_VALIDATION_ERROR' ``` `OMS_AUTH_COMMITMENT_CONSUMED` means the OTP/OIDC auth commitment has already been used. Restart the auth flow before retrying. +`OMS_TRANSACTION_EXECUTION_UNCONFIRMED` means transaction preparation succeeded, but the execute request failed before the SDK could confirm whether the transaction was submitted. The error includes `txnId` when available; do not blindly resend the same write solely because the upstream failure looked temporary. + +`OMS_TRANSACTION_STATUS_LOOKUP_FAILED` means the transaction was submitted, but post-submit status polling failed. The error includes `txnId` and is retryable by checking status again with `getTransactionStatus`. + +`upstreamError` is normalized diagnostic detail from a remote OMS service response or transport failure. Use the SDK-level `code` for application branching; use `upstreamError` for logging and service-specific troubleshooting. + +`retryable` describes the failed SDK operation, not the whole user intent. For example, a retryable transaction status lookup failure means retry `getTransactionStatus`; it does not mean blindly resend the original transaction write. + | Class | Typical use | |---|---| | `OmsSessionError` | Missing, expired, or stale wallet session. | | `OmsRequestError` | Network, fetch, or non-2xx HTTP failures. | | `OmsResponseError` | Invalid JSON or malformed API responses. | -| `OmsTransactionError` | Transaction was submitted but status polling failed; includes `txnId`. | +| `OmsTransactionError` | Transaction execution could not be confirmed or submitted transaction status polling failed; includes `txnId` when available. | | `OmsWalletSelectionError` | Manual wallet selection is stale, invalid, or already processing an action. | | `OmsValidationError` | SDK-side validation failures before a request is sent. | diff --git a/PUBLISHING.md b/PUBLISHING.md index cc35ae2..4993c30 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,10 +1,13 @@ # Publishing -Publish the SDK before the wagmi connector. The connector has an exact peer dependency on the SDK -version, so the SDK package must exist in the npm registry first. +The SDK and wagmi connector release in lockstep. The connector source manifest keeps +`@0xsequence/typescript-sdk` as `workspace:*` in both `peerDependencies` and `devDependencies`. +This gives local development a workspace link, and `pnpm pack` / `pnpm publish` rewrites the +published peer dependency to the exact release version. -Do not use a recursive workspace publish while the connector's peer dependency is intentionally -lockstep with the SDK version. Publish each package explicitly, in order. +Do not replace the connector's SDK peer with a literal version in source, and do not publish with +`npm publish`. Use pnpm from the workspace root so the `workspace:*` protocol is rewritten before +the package reaches npm. ## Before Merging The Release PR @@ -12,7 +15,11 @@ Before publishing a new alpha version, update these values to the same exact ver - `package.json` `version` - `packages/oms-wallet-wagmi-connector/package.json` `version` + +Leave these values as `workspace:*`: + - `packages/oms-wallet-wagmi-connector/package.json` `peerDependencies["@0xsequence/typescript-sdk"]` +- `packages/oms-wallet-wagmi-connector/package.json` `devDependencies["@0xsequence/typescript-sdk"]` ## After The Release PR Is Merged @@ -24,7 +31,7 @@ git pull pnpm install --frozen-lockfile ``` -2. Capture the release version and verify package versions: +2. Capture the release version and verify package metadata: ```bash VERSION=$(node -p "require('./package.json').version") @@ -34,53 +41,53 @@ pnpm check:package-versions 3. Run release checks: ```bash -pnpm exec tsc --noEmit pnpm test +pnpm --filter @0xsequence/oms-wallet-wagmi-connector test pnpm build pnpm build:node-example pnpm build:example pnpm build:trails-actions-example -pnpm --filter @0xsequence/oms-wallet-wagmi-connector test -pnpm --filter @0xsequence/oms-wallet-wagmi-connector build pnpm build:wagmi-example ``` -4. Dry-run the SDK publish: +4. Dry-run the filtered workspace publish: ```bash -pnpm publish --dry-run --no-git-checks --tag alpha --access public +pnpm --filter @0xsequence/typescript-sdk \ + --filter @0xsequence/oms-wallet-wagmi-connector \ + publish --dry-run --no-git-checks --tag alpha --access public ``` -5. Dry-run the wagmi connector publish: +If the dry run reports no new packages, the version is already published. Stop and verify the +intended release version before continuing. -```bash -pnpm --filter @0xsequence/oms-wallet-wagmi-connector publish --dry-run --no-git-checks --tag alpha --access public -``` - -6. Log in to npm if needed: +5. Log in to npm if needed: ```bash pnpm npm login pnpm npm whoami ``` -7. Publish the SDK: +6. Publish both workspace packages from the root: ```bash -pnpm publish --tag alpha --access public -pnpm view @0xsequence/typescript-sdk@$VERSION version +pnpm --filter @0xsequence/typescript-sdk \ + --filter @0xsequence/oms-wallet-wagmi-connector \ + publish --tag alpha --access public ``` -8. Publish the wagmi connector: +If the filtered publish is interrupted after the SDK is published, rerun the connector publish with +pnpm: ```bash pnpm --filter @0xsequence/oms-wallet-wagmi-connector publish --tag alpha --access public -pnpm view @0xsequence/oms-wallet-wagmi-connector@$VERSION version ``` -9. Verify the alpha dist tags: +7. Verify published versions and alpha dist tags: ```bash +pnpm view @0xsequence/typescript-sdk@$VERSION version +pnpm view @0xsequence/oms-wallet-wagmi-connector@$VERSION version pnpm view @0xsequence/typescript-sdk@alpha version pnpm view @0xsequence/oms-wallet-wagmi-connector@alpha version ``` diff --git a/README.md b/README.md index b9fd98b..8807bdb 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,24 @@ To end the session, call: await oms.wallet.signOut() ``` +## Errors + +Public methods throw `OmsSdkError` subclasses with stable SDK fields such as `code`, `operation`, `status`, and `retryable`. When a failure comes from a remote OMS service response or transport failure, the error also includes `upstreamError` with normalized WaaS or indexer details for logging and service-specific troubleshooting. Application logic should usually branch on the SDK-level `code`. + +For transaction writes, `OMS_TRANSACTION_EXECUTION_UNCONFIRMED` means the SDK has a `txnId` from preparation, but the execute request failed before the SDK could confirm whether the transaction was submitted; do not blindly resend the same write. `OMS_TRANSACTION_STATUS_LOOKUP_FAILED` means the transaction was submitted but status polling failed, so retry status lookup with the returned `txnId`. `retryable` describes the failed SDK operation, not the whole user intent. + +```typescript +import { OmsSdkError } from '@0xsequence/typescript-sdk' + +try { + await oms.wallet.startEmailAuth({ email: 'user@example.com' }) +} catch (err) { + if (err instanceof OmsSdkError) { + console.log(err.code, err.operation, err.upstreamError) + } +} +``` + ## Networks The SDK exports `Networks`, `supportedNetworks`, `findNetworkById(id)`, and `findNetworkByName(name)` for the networks currently configured by OMS. Each network has `id`, `name`, `nativeTokenSymbol`, `explorerUrl`, and `displayName`. `name` is the registry/routing slug, while `displayName` is the user-facing label. diff --git a/TESTING.md b/TESTING.md index ab66cf8..8aabe83 100644 --- a/TESTING.md +++ b/TESTING.md @@ -51,6 +51,34 @@ How testing works in this repo. `AGENTS.md` points here so agents know how to ve - Integration tests that need `OMS_PROJECT_ACCESS_KEY` are gated behind the CI secret; they may be skipped locally when the env var is absent +### Public error contract tests + +- Use `docs/error-contracts.md` as the audit matrix for public SDK/connector error surfaces, + recovery semantics, `upstreamError` expectations, and owning tests. +- Exercise real public runtime APIs such as `oms.wallet.*`, `oms.indexer.*`, exported storage + managers, signers, or wagmi connector/provider methods. +- Do not snapshot manually constructed `OmsSdkError` subclasses unless the error class or helper + is the unit under test. +- Mock only external boundaries: `fetch`, browser globals, storage availability, signer behavior, + timers, or backend responses. +- Snapshot only stable public fields: `name`, `code`, `operation`, `message`, `status`, + `retryable`, `txnId`, and `upstreamError`. +- Do not snapshot `stack`, raw `cause`, generated WebRPC internals, request headers, timestamps, + or full backend payloads. +- Keep backend/upstream mapping tests representative rather than exhaustive per method; cover + each transport family through real public calls. +- Include `upstreamError` only when the tested path truthfully crosses a remote service or + transport boundary; SDK-local failures should assert no `upstreamError`. +- Snapshot changes are not automatically regressions. Decide whether the new error shape is the + intended public contract: if correct, update the snapshot and any related docs/type tests; if + accidental, fix the implementation. Never update snapshots blindly. +- Treat `code` and `operation` as stronger contract fields than `message`. Message changes are + allowed when intentional, but they should be reviewed as user-visible API/UX changes. +- `upstreamError` is normalized diagnostic detail from a remote OMS service response or transport + failure. Application logic should usually branch on the SDK-level `code`. +- `retryable` describes the failed SDK operation, not the whole user intent. A retryable status + lookup failure does not mean a transaction write should be blindly resent. + ## Execution summary | Goal | Command | diff --git a/docs/error-contracts.md b/docs/error-contracts.md new file mode 100644 index 0000000..b47ac67 --- /dev/null +++ b/docs/error-contracts.md @@ -0,0 +1,57 @@ +# Public Error Contracts + +This matrix is the audit surface for SDK and connector error behavior. It documents which public +runtime surfaces can fail, what error shape users should see, what recovery decision the error +supports, whether `upstreamError` should be present, and which test owns the contract. + +## Terms + +- `upstreamError` is normalized diagnostic detail from a remote OMS service response or transport + failure. It is for logging and service-specific troubleshooting. Application logic should usually + branch on the SDK-level `code`. +- `retryable` describes the failed SDK operation, not the whole user intent. A retryable status + lookup failure does not mean a transaction write should be blindly resent. +- `OMS_TRANSACTION_EXECUTION_UNCONFIRMED` means transaction preparation succeeded, but the execute + request failed before the SDK could confirm whether the transaction was submitted. Do not blindly + resend the same write solely because the upstream failure looked temporary. +- `OMS_TRANSACTION_STATUS_LOOKUP_FAILED` means the transaction was submitted, but post-submit status + polling failed. Retry by checking transaction status with the returned `txnId`. + +## SDK Matrix + +| Public surface | Failure family | User-facing error | Recovery meaning | `upstreamError` | Covering test | +|---|---|---|---|---|---| +| `oms.wallet.startEmailAuth`, representative WaaS methods | WaaS transport failure | `OmsRequestError`, `OMS_REQUEST_FAILED`, operation-specific, retryable when transport/5xx | Retry the same read/auth request when appropriate | Present | `snapshots WaaS transport failures with upstream details` | +| `oms.wallet.startEmailAuth`, representative WaaS methods | WaaS domain error | SDK-specific code such as `OMS_AUTH_COMMITMENT_CONSUMED` | Follow the SDK code; for consumed commitments, restart auth | Present | `snapshots WaaS domain errors with upstream details` | +| `oms.wallet.startEmailAuth`, representative WaaS methods | WaaS HTTP error | `OmsRequestError`, `OMS_HTTP_ERROR`, status, retryable for 5xx | Use SDK code/status for branching; log upstream detail | Present | `snapshots WaaS HTTP responses with upstream details` | +| `oms.wallet.completeEmailAuth` and pending wallet selection actions | Local auth/session/selection state | `OmsSessionError` or `OmsWalletSelectionError` | Fix local flow state or restart auth; do not look for backend diagnostics | Absent | `snapshots email auth completion local state errors`; `snapshots pending wallet selection local state errors` | +| `oms.wallet.startOidcRedirectAuth`, `completeOidcRedirectAuth`, `signInWithOidcRedirect` | Local OIDC config, callback, storage, or state mismatch | `OmsSessionError` or wrapped SDK-local error | Fix redirect config/state or restart OIDC flow | Absent | `snapshots OIDC local error contracts without upstream details`; `snapshots OIDC redirect real-flow local mismatch errors`; `snapshots signInWithOidcRedirect missing assignUrl after real redirect start` | +| Protected wallet methods: `getIdToken`, `signMessage`, `signTypedData`, `sendTransaction`, `callContract`, `getTransactionStatus`, `listAccess`, `listAccessPages`, `revokeAccess` | Missing, expired, or stale local session | `OmsSessionError` | Authenticate again or recover local session; no remote request was made | Absent | `snapshots missing-session contracts for protected wallet methods` | +| `oms.wallet.signMessage`, `signTypedData`, `getIdToken`, `sendTransaction`, `callContract` | SDK-local validation or fee-selection failure | `OmsValidationError` | Correct parameters or local fee selection; do not retry as an upstream outage | Absent | `snapshots SDK-local errors without upstream details`; `snapshots transaction local validation errors without upstream details` | +| `oms.wallet.isValidMessageSignature`, `isValidTypedDataSignature` | WaaS validation backend failure | `OmsRequestError` or `OmsResponseError` with validation operation | Retry based on SDK code/status; log upstream detail | Present | `snapshots signature validation backend failures with upstream details` | +| `oms.wallet.sendTransaction`, `callContract` | Execute request fails after prepare | `OmsTransactionError`, `OMS_TRANSACTION_EXECUTION_UNCONFIRMED`, `retryable: false`, `txnId` when available | Do not blindly resend the write; preserve `txnId` and upstream detail for diagnostics | Present when execute crossed transport/upstream boundary | `snapshots transaction execute failures as unconfirmed writes` | +| `oms.wallet.sendTransaction`, `callContract` | Submitted transaction status polling fails | `OmsTransactionError`, `OMS_TRANSACTION_STATUS_LOOKUP_FAILED`, `retryable: true`, `txnId` | Retry status lookup, not the original write | Present when polling crossed transport/upstream boundary | `snapshots transaction status polling failures with txn and upstream details`; `snapshots transaction status polling backend errors as retryable` | +| `oms.wallet.getTransactionStatus` | Direct status lookup backend failure | `OmsRequestError` or `OmsResponseError` with status operation | Retry status lookup or surface backend status to the user | Present | `snapshots direct transaction status backend errors with upstream details` | +| `oms.wallet.listAccess`, `listAccessPages`, `revokeAccess` | WaaS access backend failure | `OmsRequestError` or `OmsResponseError` with access operation | Retry based on SDK code/status; log upstream detail | Present | `snapshots access backend errors with upstream details` | +| `oms.indexer.getTokenBalances`, `getNativeTokenBalance` | Indexer backend, transport, malformed JSON, or malformed payload | `OmsRequestError` or `OmsResponseError` with indexer operation | Retry based on SDK code/status; log upstream detail | Present for remote/transport response failures | `snapshots indexer backend errors with upstream details`; `snapshots native balance indexer errors with upstream details`; `snapshots indexer transport failures with upstream details`; `snapshots indexer malformed response errors with upstream details` | +| `oms.indexer.getTokenBalances`, `getNativeTokenBalance` | Indexer non-JSON HTTP body | `OmsRequestError`, `OMS_HTTP_ERROR`, sanitized message | Do not expose raw upstream HTML/text bodies; log normalized detail | Present, sanitized | `snapshots indexer non-JSON HTTP errors without raw upstream bodies` | +| Exported storage managers and credential signers | Local runtime capability or storage failure | Native `Error` or signer/storage-specific runtime error | Fix local runtime support or storage availability | Absent | `snapshots exported storage and signer runtime errors` | +| Exported `OmsSdkError` classes and `isOmsSdkError` | Error class/helper field contract | Stable public fields on constructed errors | Use only when the error class/helper is the unit under test | As constructed | `snapshots exported error helper and subclass fields` | + +## Connector Matrix + +| Public surface | Failure family | User-facing error | Recovery meaning | `upstreamError` | Covering test | +|---|---|---|---|---|---| +| `omsWalletConnector().connect` | No active OMS wallet session | `OmsWalletProviderRpcError`, `4100` through wagmi connect wrapping | Authenticate with the OMS SDK before connecting wagmi | Not applicable | `rejects connect when there is no active OMS wallet session` | +| `omsWalletConnector().connect` | Initial chain not configured in wagmi | Provider/connector chain error | Configure the chain in wagmi or choose a configured chain | Not applicable | `rejects connect when initialChainId is not configured in wagmi` | +| `omsWalletConnector().connect` | No configured wagmi chain is supported by OMS | Provider/connector chain error | Configure at least one chain supported by OMS | Not applicable | `rejects connect when no configured wagmi chain is supported by OMS` | +| `connector.getAccounts`, `isAuthorized`, `disconnect`, `getProvider` | Disconnected connector state | Wagmi connector state errors or non-throwing state results | Reconnect through wagmi when needed | Not applicable | `disconnects wagmi without signing out the OMS wallet`; `does not reconnect automatically after disconnecting and refreshing`; `keeps session expiry disconnect handling after reconnect` | +| `OmsWalletProvider.request({method: "eth_requestAccounts"})` | No active OMS wallet session | `OmsWalletProviderRpcError`, `4100` | Authenticate with the OMS SDK before requesting accounts | Not applicable | `rejects eth_requestAccounts without an active OMS wallet session` | +| `OmsWalletProvider.request`, unsupported methods | Unsupported raw signing or unsupported RPC method | `OmsWalletProviderRpcError`, `4200` | Use supported methods only | Not applicable | `rejects eth_sign because OMS Wallet does not raw-sign messages`; `rejects legacy typed data signing instead of treating it as v4` | +| `personal_sign`, `eth_signTypedData_v4` | Provider parameter validation or account mismatch | `OmsWalletProviderRpcError`, `-32602` or `4100` | Correct params or use the active OMS account; no wallet SDK call should be made for malformed inputs | Not applicable | `rejects provider signing validation errors before calling OMS` | +| `eth_sendTransaction`, `wallet_sendTransaction` | Provider transaction parameter validation | `OmsWalletProviderRpcError`, `-32602`, `4100`, or `4200` | Correct params; no wallet SDK transaction should be sent for malformed inputs | Not applicable | `rejects provider transaction validation errors before calling OMS`; `rejects unknown transaction fields`; `rejects non-quantity transaction values at the provider boundary`; `rejects waitForStatus false because wagmi sendTransaction requires a hash` | +| `eth_sendTransaction`, `wallet_sendTransaction` | Chain configured by wagmi but unsupported by OMS | `OmsWalletProviderRpcError`, `4901` | Choose an OMS-supported chain; no wallet SDK transaction should be sent | Not applicable | `rejects transactions for wagmi-configured chains that OMS does not support` | +| `eth_sendTransaction`, `wallet_sendTransaction` | SDK transaction failure | `OmsWalletProviderRpcError`, `-32603`, SDK error preserved in `data` | Preserve SDK recovery fields through provider and wagmi wrapping | SDK error may carry `upstreamError` in `data` | `wraps SDK transaction failures as provider RPC errors`; `preserves SDK transaction error details through wagmi sendTransaction wrapping` | +| `eth_sendTransaction`, `wallet_sendTransaction` | OMS response lacks EVM transaction hash | `OmsWalletProviderRpcError`, `-32603`, response preserved | Surface the OMS transaction id when available; wagmi requires an EVM hash | Not applicable unless response data carries it | `rejects with the OMS response when a sent transaction has no EVM hash` | +| `wallet_switchEthereumChain`, `switchChain` | Chain not configured in wagmi or unsupported by OMS | `OmsWalletProviderRpcError` or wagmi switch error, `4901` | Configure the chain and ensure OMS supports it | Not applicable | `rejects provider chain switches to OMS-supported chains that are not configured in wagmi`; `rejects provider chain switches to wagmi-configured chains that OMS does not support`; `rejects wagmi switchChain calls to wagmi-configured chains that OMS does not support`; `uses and validates initialChainId`; `rejects initialChainId when OMS does not support it` | +| `eth_chainId`, `net_version`, `eth_accounts`, `wallet_getCapabilities`, `stringToPersonalSignHex` | Non-throwing public utility/state calls | Return current state or converted value | No error contract expected unless implementation changes | Not applicable | Covered indirectly by behavior tests or intentionally skipped | diff --git a/packages/oms-wallet-wagmi-connector/package.json b/packages/oms-wallet-wagmi-connector/package.json index a7ba0cd..64b674e 100644 --- a/packages/oms-wallet-wagmi-connector/package.json +++ b/packages/oms-wallet-wagmi-connector/package.json @@ -36,7 +36,7 @@ "test:watch": "vitest" }, "peerDependencies": { - "@0xsequence/typescript-sdk": "0.1.0-alpha.3", + "@0xsequence/typescript-sdk": "workspace:*", "@wagmi/core": ">=3.5.0 <4", "viem": ">=2.48.4 <3" }, diff --git a/packages/oms-wallet-wagmi-connector/src/omsWalletConnector.ts b/packages/oms-wallet-wagmi-connector/src/omsWalletConnector.ts index 946863c..6407272 100644 --- a/packages/oms-wallet-wagmi-connector/src/omsWalletConnector.ts +++ b/packages/oms-wallet-wagmi-connector/src/omsWalletConnector.ts @@ -1,7 +1,7 @@ import { createConnector } from "@wagmi/core"; import { getAddress, numberToHex, SwitchChainError, type Address } from "viem"; -import { OmsWalletProvider } from "./provider.js"; +import { OmsWalletProvider, OmsWalletProviderRpcError } from "./provider.js"; import type { MaybePromise, OmsWalletClientLike, @@ -116,12 +116,12 @@ export function omsWalletConnector(parameters: OmsWalletConnectorParameters) { const client = await resolveClient(); subscribeSessionExpired(client); if (!client.wallet.walletAddress) { - throw new Error("No active OMS wallet session. Authenticate with the OMS SDK before connecting through wagmi."); + throw new OmsWalletProviderRpcError(4100, "No active OMS wallet session. Authenticate with the OMS SDK before connecting through wagmi."); } const nextAccounts = await accounts(); if (!nextAccounts.length) { - throw new Error("No active OMS wallet session. Authenticate with the OMS SDK before connecting through wagmi."); + throw new OmsWalletProviderRpcError(4100, "No active OMS wallet session. Authenticate with the OMS SDK before connecting through wagmi."); } provider?.emit("accountsChanged", nextAccounts); return nextAccounts; diff --git a/packages/oms-wallet-wagmi-connector/src/provider.ts b/packages/oms-wallet-wagmi-connector/src/provider.ts index d748536..fd5d406 100644 --- a/packages/oms-wallet-wagmi-connector/src/provider.ts +++ b/packages/oms-wallet-wagmi-connector/src/provider.ts @@ -15,6 +15,7 @@ import type { OmsWalletClientLike, OmsWalletConnectorParameters, OmsWalletNetwork, + OmsWalletSendTransactionResponse, OmsWalletProviderTransactionRequest, OmsWalletTransactionOptions, } from "./types.js"; @@ -159,16 +160,21 @@ export class OmsWalletProvider { waitForStatus: true, } as const; const value = request.value === undefined ? 0n : normalizeValue(request.value); - const response = request.data === undefined - ? await client.wallet.sendTransaction({ - ...transactionBase, - value, - }) - : await client.wallet.sendTransaction({ - ...transactionBase, - value, - data: request.data, - }); + let response: OmsWalletSendTransactionResponse; + try { + response = request.data === undefined + ? await client.wallet.sendTransaction({ + ...transactionBase, + value, + }) + : await client.wallet.sendTransaction({ + ...transactionBase, + value, + data: request.data, + }); + } catch (error) { + throw transactionFailed(error); + } if (!response.txnHash || !isHex(response.txnHash)) { throw new OmsWalletProviderRpcError( @@ -315,6 +321,20 @@ function missingTransactionHashMessage(response: {txnId?: unknown}): string { return `OMS transaction did not produce the EVM transaction hash required by wagmi sendTransaction.${suffix}`; } +function transactionFailed(error: unknown): OmsWalletProviderRpcError { + if (error instanceof OmsWalletProviderRpcError) { + return error; + } + return new OmsWalletProviderRpcError(-32603, errorMessage(error), error); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return "OMS wallet transaction failed."; +} + function isQuantity(value: unknown): value is Hex { return typeof value === "string" && /^0x(?:0|[1-9a-f][0-9a-f]*)$/iu.test(value); } diff --git a/packages/oms-wallet-wagmi-connector/tests/omsWalletConnector.test.ts b/packages/oms-wallet-wagmi-connector/tests/omsWalletConnector.test.ts index 7fccf2e..18d3125 100644 --- a/packages/oms-wallet-wagmi-connector/tests/omsWalletConnector.test.ts +++ b/packages/oms-wallet-wagmi-connector/tests/omsWalletConnector.test.ts @@ -2,7 +2,8 @@ import { createConfig, createStorage, connect, disconnect, reconnect, sendTransa import { describe, expect, it, vi } from "vitest"; import { http, type Address, type Chain, type Hex } from "viem"; -import { omsWalletConnector, stringToPersonalSignHex, type OmsWalletClientLike } from "../src/index.js"; +import { OmsTransactionError } from "../../../src/index.js"; +import { OmsWalletProviderRpcError, omsWalletConnector, stringToPersonalSignHex, type OmsWalletClientLike } from "../src/index.js"; const polygon = { id: 137, @@ -18,6 +19,13 @@ const mainnet = { rpcUrls: {default: {http: ["https://mainnet.example"]}}, } as const satisfies Chain; +const optimism = { + id: 10, + name: "Optimism", + nativeCurrency: {name: "Ether", symbol: "ETH", decimals: 18}, + rpcUrls: {default: {http: ["https://optimism.example"]}}, +} as const satisfies Chain; + const networks = [ { id: 137, @@ -46,6 +54,32 @@ describe("omsWalletConnector", () => { })).rejects.toThrow("Authenticate with the OMS SDK before connecting through wagmi."); }); + it("rejects connect when initialChainId is not configured in wagmi", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createConfig({ + chains: [polygon], + connectors: [omsWalletConnector({client, networks, initialChainId: mainnet.id})], + transports: { + [polygon.id]: http(), + }, + }); + + await expect(connect(config, {connector: config.connectors[0]})).rejects.toThrow("Chain 1 is not configured in wagmi."); + }); + + it("rejects connect when no configured wagmi chain is supported by OMS", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createConfig({ + chains: [optimism], + connectors: [omsWalletConnector({client, networks})], + transports: { + [optimism.id]: http(), + }, + }); + + await expect(connect(config, {connector: config.connectors[0]})).rejects.toThrow("No wagmi chain is supported by OMS."); + }); + it("signs messages and typed data through the OMS wallet", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); const config = createWagmiConfig(client); @@ -76,6 +110,49 @@ describe("omsWalletConnector", () => { }); }); + it("rejects provider signing validation errors before calling OMS", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createWagmiConfig(client); + + await connect(config, {connector: config.connectors[0]}); + const provider = await config.connectors[0].getProvider(); + + await expect(provider.request({ + method: "personal_sign", + params: {message: "hello"}, + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32602, + message: "personal_sign requires positional parameters.", + }); + await expect(provider.request({ + method: "personal_sign", + params: [123, client.wallet.walletAddress], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32602, + message: "Signing message must be a string.", + }); + await expect(provider.request({ + method: "eth_signTypedData_v4", + params: [client.wallet.walletAddress, "{"], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32602, + message: "Typed data must be JSON when passed as a string.", + }); + await expect(provider.request({ + method: "eth_signTypedData_v4", + params: ["0x1111111111111111111111111111111111111111", {message: "hello"}], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: 4100, + message: "eth_signTypedData_v4 requested 0x1111111111111111111111111111111111111111, but the active OMS wallet is 0x9999999999999999999999999999999999999999.", + }); + expect(client.wallet.signMessage).not.toHaveBeenCalled(); + expect(client.wallet.signTypedData).not.toHaveBeenCalled(); + }); + it("sends transactions through the OMS wallet and returns the EVM transaction hash", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); const config = createWagmiConfig(client); @@ -96,6 +173,61 @@ describe("omsWalletConnector", () => { }); }); + it("rejects provider transaction validation errors before calling OMS", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createWagmiConfig(client); + const connector = config.connectors[0]; + + await connect(config, {connector}); + const provider = await connector.getProvider(); + + await expect(provider.request({ + method: "eth_sendTransaction", + params: {to: "0x1111111111111111111111111111111111111111"}, + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32602, + message: "eth_sendTransaction requires positional parameters.", + }); + await expect(provider.request({ + method: "eth_sendTransaction", + params: [{ + from: client.wallet.walletAddress, + value: "0x1", + }], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: 4200, + message: "Unsupported OMS provider method: eth_sendTransaction without a recipient address; contract deployment is not supported by the current OMS wallet SDK.", + }); + await expect(provider.request({ + method: "eth_sendTransaction", + params: [{ + from: client.wallet.walletAddress, + to: "not-an-address", + value: "0x1", + }], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: 4200, + message: "Unsupported OMS provider method: eth_sendTransaction without a recipient address; contract deployment is not supported by the current OMS wallet SDK.", + }); + await expect(provider.request({ + method: "eth_sendTransaction", + params: [{ + from: client.wallet.walletAddress, + to: "0x1111111111111111111111111111111111111111", + value: "0x1", + chainId: "not-a-chain", + }], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32602, + message: "Chain ID must be a positive safe integer.", + }); + expect(client.wallet.sendTransaction).not.toHaveBeenCalled(); + }); + it("rejects non-quantity transaction values at the provider boundary", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); const config = createWagmiConfig(client); @@ -216,6 +348,109 @@ describe("omsWalletConnector", () => { }); }); + it("rejects transactions for wagmi-configured chains that OMS does not support", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createConfig({ + chains: [polygon, optimism], + connectors: [omsWalletConnector({client, networks})], + transports: { + [polygon.id]: http(), + [optimism.id]: http(), + }, + }); + const connector = config.connectors[0]; + + await connect(config, {connector}); + const provider = await connector.getProvider(); + + await expect(provider.request({ + method: "eth_sendTransaction", + params: [{ + from: client.wallet.walletAddress, + to: "0x1111111111111111111111111111111111111111", + value: "0x1", + chainId: "0xa", + }], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: 4901, + message: "OMS does not support chain 10.", + }); + expect(client.wallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("wraps SDK transaction failures as provider RPC errors", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const sdkError = createTransactionExecutionError(); + client.wallet.sendTransaction = vi.fn(async () => { + throw sdkError; + }); + const config = createWagmiConfig(client); + const connector = config.connectors[0]; + + await connect(config, {connector}); + const provider = await connector.getProvider(); + + await expect(provider.request({ + method: "eth_sendTransaction", + params: [{ + from: client.wallet.walletAddress, + to: "0x1111111111111111111111111111111111111111", + value: "0x1", + }], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32603, + message: "Transaction execution failed before status could be confirmed", + data: expect.objectContaining({ + name: "OmsTransactionError", + code: "OMS_TRANSACTION_EXECUTION_UNCONFIRMED", + operation: "wallet.execute", + retryable: false, + txnId: "txn-execute", + upstreamError: expect.objectContaining({ + service: "waas", + name: "WebrpcRequestFailed", + code: -1, + }), + }), + }); + }); + + it("preserves SDK transaction error details through wagmi sendTransaction wrapping", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + client.wallet.sendTransaction = vi.fn(async () => { + throw createTransactionExecutionError(); + }); + const config = createWagmiConfig(client); + + await connect(config, {connector: config.connectors[0]}); + + await expect(sendTransaction(config, { + to: "0x1111111111111111111111111111111111111111", + value: 1n, + })).rejects.toMatchObject({ + cause: expect.objectContaining({ + code: -32603, + cause: expect.objectContaining({ + code: -32603, + data: expect.objectContaining({ + name: "OmsTransactionError", + code: "OMS_TRANSACTION_EXECUTION_UNCONFIRMED", + operation: "wallet.execute", + retryable: false, + txnId: "txn-execute", + upstreamError: expect.objectContaining({ + service: "waas", + name: "WebrpcRequestFailed", + code: -1, + }), + }), + }), + }), + }); + }); + it("ignores wallet-managed transaction fields when sending transactions", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); const config = createWagmiConfig(client); @@ -291,6 +526,20 @@ describe("omsWalletConnector", () => { }); }); + it("rejects eth_requestAccounts without an active OMS wallet session", async () => { + const client = createClient(); + const config = createWagmiConfig(client); + const provider = await config.connectors[0].getProvider(); + + await expect(provider.request({ + method: "eth_requestAccounts", + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: 4100, + message: "No active OMS wallet session. Authenticate with the OMS SDK before connecting through wagmi.", + }); + }); + it("switches the OMS network used for signing and transactions", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); const config = createWagmiConfig(client); @@ -345,6 +594,52 @@ describe("omsWalletConnector", () => { await expect(connector.getChainId()).resolves.toBe(polygon.id); }); + it("rejects provider chain switches to wagmi-configured chains that OMS does not support", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createConfig({ + chains: [polygon, optimism], + connectors: [omsWalletConnector({client, networks})], + transports: { + [polygon.id]: http(), + [optimism.id]: http(), + }, + }); + const connector = config.connectors[0]; + + await connect(config, {connector}); + const provider = await connector.getProvider(); + await expect(provider.request({ + method: "wallet_switchEthereumChain", + params: [{chainId: "0xa"}], + })).rejects.toMatchObject({ + name: "OmsWalletProviderRpcError", + code: 4901, + message: "OMS does not support chain 10.", + }); + + expect(config.state.connections.get(config.state.current!)?.chainId).toBe(polygon.id); + await expect(connector.getChainId()).resolves.toBe(polygon.id); + }); + + it("rejects wagmi switchChain calls to wagmi-configured chains that OMS does not support", async () => { + const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); + const config = createConfig({ + chains: [polygon, optimism], + connectors: [omsWalletConnector({client, networks})], + transports: { + [polygon.id]: http(), + [optimism.id]: http(), + }, + }); + const connector = config.connectors[0]; + + await connect(config, {connector}); + + await expect(switchChain(config, {chainId: optimism.id})).rejects.toThrow("OMS does not support chain 10."); + expect(config.state.connections.get(config.state.current!)?.chainId).toBe(polygon.id); + await expect(connector.getChainId()).resolves.toBe(polygon.id); + }); + it("uses and validates initialChainId", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); const config = createWagmiConfig(client, {initialChainId: mainnet.id}); @@ -360,18 +655,12 @@ describe("omsWalletConnector", () => { it("rejects initialChainId when OMS does not support it", async () => { const client = createClient({walletAddress: "0x9999999999999999999999999999999999999999"}); - const unsupported = { - id: 10, - name: "Optimism", - nativeCurrency: {name: "Ether", symbol: "ETH", decimals: 18}, - rpcUrls: {default: {http: ["https://optimism.example"]}}, - } as const satisfies Chain; const config = createConfig({ - chains: [polygon, unsupported], - connectors: [omsWalletConnector({client, networks, initialChainId: unsupported.id})], + chains: [polygon, optimism], + connectors: [omsWalletConnector({client, networks, initialChainId: optimism.id})], transports: { [polygon.id]: http(), - [unsupported.id]: http(), + [optimism.id]: http(), }, }); @@ -504,6 +793,18 @@ describe("omsWalletConnector", () => { })).rejects.toMatchObject({code: 4200}); expect(client.wallet.signTypedData).not.toHaveBeenCalled(); }); + + it("preserves the exported provider RPC error field contract", () => { + const data = {txnId: "txn-1"}; + const error = new OmsWalletProviderRpcError(-32603, "Provider failed.", data); + + expect(error).toMatchObject({ + name: "OmsWalletProviderRpcError", + code: -32603, + message: "Provider failed.", + data, + }); + }); }); function createWagmiConfig( @@ -537,6 +838,22 @@ function createMemoryStorage(): Storage { }); } +function createTransactionExecutionError(): OmsTransactionError { + return new OmsTransactionError({ + code: "OMS_TRANSACTION_EXECUTION_UNCONFIRMED", + operation: "wallet.execute", + txnId: "txn-execute", + retryable: false, + upstreamError: { + service: "waas", + name: "WebrpcRequestFailed", + code: -1, + message: "request failed", + }, + message: "Transaction execution failed before status could be confirmed", + }); +} + interface TestOmsWalletClient extends OmsWalletClientLike { wallet: OmsWalletClientLike["wallet"] & { signOut: ReturnType diff --git a/scripts/check-package-versions.cjs b/scripts/check-package-versions.cjs index 31a4ace..ffff0ff 100644 --- a/scripts/check-package-versions.cjs +++ b/scripts/check-package-versions.cjs @@ -6,6 +6,7 @@ const rootPackage = readPackage('package.json') const packagePaths = [ 'packages/oms-wallet-wagmi-connector/package.json', ] +const exactWorkspaceProtocol = 'workspace:*' let hasMismatch = false @@ -16,16 +17,14 @@ for (const packagePath of packagePaths) { report(`${packageJson.name} version ${packageJson.version} does not match ${rootPackage.name} version ${rootPackage.version}.`) } - const sdkPeerVersion = packageJson.peerDependencies?.[rootPackage.name] - if (sdkPeerVersion !== undefined && sdkPeerVersion !== rootPackage.version) { - report(`${packageJson.name} peer dependency ${rootPackage.name}@${sdkPeerVersion} does not match ${rootPackage.version}.`) - } + checkWorkspaceReference(packageJson.name, 'peer dependency', packageJson.peerDependencies?.[rootPackage.name]) + checkWorkspaceReference(packageJson.name, 'dev dependency', packageJson.devDependencies?.[rootPackage.name]) } if (hasMismatch) { process.exitCode = 1 } else { - console.log(`Publishable package versions match ${rootPackage.version}.`) + console.log(`Publishable package versions match ${rootPackage.version}; SDK workspace references use ${exactWorkspaceProtocol}.`) } function readPackage(packagePath) { @@ -36,3 +35,9 @@ function report(message) { hasMismatch = true console.error(message) } + +function checkWorkspaceReference(packageName, dependencyType, version) { + if (version !== undefined && version !== exactWorkspaceProtocol) { + report(`${packageName} ${dependencyType} ${rootPackage.name}@${version} must use ${exactWorkspaceProtocol}; pnpm publish rewrites it to ${rootPackage.version}.`) + } +} diff --git a/src/clients/indexerClient.ts b/src/clients/indexerClient.ts index 39eadf3..3f7fb96 100644 --- a/src/clients/indexerClient.ts +++ b/src/clients/indexerClient.ts @@ -1,7 +1,7 @@ // Converted from Swift IndexerClient. import {HttpClient} from "../httpClient.js"; -import {errorMessage, OmsRequestError, OmsResponseError} from "../errors.js"; +import {errorMessage, OmsRequestError, OmsResponseError, type OmsUpstreamError} from "../errors.js"; import type {Network} from "../networks.js"; import {IndexerOperation} from "../operations.js"; @@ -246,6 +246,7 @@ export class IndexerClient { throw new OmsRequestError({ operation, retryable: true, + upstreamError: indexerRequestFailure(error), cause: error, message: errorMessage(error), }); @@ -254,24 +255,32 @@ export class IndexerClient { let payload: T; if (response.statusCode < 200 || response.statusCode >= 300) { const errorPayload = parseJsonOrText(response.body); + const message = responseErrorMessage(errorPayload, operation, response.statusCode); throw new OmsRequestError({ code: "OMS_HTTP_ERROR", operation, status: response.statusCode, retryable: response.statusCode >= 500, + upstreamError: indexerResponseError(errorPayload, response.statusCode, message), cause: errorPayload, - message: responseErrorMessage(errorPayload, operation, response.statusCode), + message, }); } try { payload = JSON.parse(response.body) as T; } catch (error) { + const message = `Invalid JSON response from ${operation}`; throw new OmsResponseError({ operation, status: response.statusCode, + upstreamError: { + service: "indexer", + status: response.statusCode, + message, + }, cause: error, - message: `Invalid JSON response from ${operation}`, + message, }); } @@ -343,3 +352,45 @@ function parseJsonOrText(body: string): unknown { return body; } } + +function indexerRequestFailure(error: unknown): OmsUpstreamError { + const status = numberField(error, "status"); + return { + service: "indexer", + name: error instanceof Error ? error.name : stringField(error, "name"), + code: numberOrStringField(error, "code"), + message: errorMessage(error), + status, + }; +} + +function indexerResponseError(payload: unknown, status: number, fallbackMessage: string): OmsUpstreamError { + return { + service: "indexer", + name: stringField(payload, "name") ?? stringField(payload, "error"), + code: numberOrStringField(payload, "code"), + message: stringField(payload, "message") ?? fallbackMessage, + status, + }; +} + +function stringField(source: unknown, key: string): string | undefined { + const value = objectField(source, key); + return typeof value === "string" ? value : undefined; +} + +function numberField(source: unknown, key: string): number | undefined { + const value = objectField(source, key); + return typeof value === "number" ? value : undefined; +} + +function numberOrStringField(source: unknown, key: string): number | string | undefined { + const value = objectField(source, key); + return typeof value === "number" || typeof value === "string" ? value : undefined; +} + +function objectField(source: unknown, key: string): unknown { + return source && typeof source === "object" + ? (source as Record)[key] + : undefined; +} diff --git a/src/clients/walletClient.ts b/src/clients/walletClient.ts index 718ccc5..18ec12a 100644 --- a/src/clients/walletClient.ts +++ b/src/clients/walletClient.ts @@ -50,6 +50,7 @@ import { PrepareEthereumTransactionRequest, PrepareEthereumContractCallRequest, ExecuteRequest, + ExecuteResponse, TransactionStatusRequest, PrepareResponse, TransactionStatusResponse, @@ -1446,7 +1447,23 @@ export class WalletClient { request.feeOption = feeOption } - const executed = await this.client.execute(request) + let executed: ExecuteResponse + try { + executed = await this.client.execute(request) + } catch (error) { + const sdkError = toOmsSdkError(error, WalletOperation.execute) + throw new OmsTransactionError({ + code: "OMS_TRANSACTION_EXECUTION_UNCONFIRMED", + operation: WalletOperation.execute, + txnId: params.prepared.txnId, + status: sdkError.status, + retryable: false, + upstreamError: sdkError.upstreamError, + cause: sdkError, + message: "Transaction execution failed before status could be confirmed", + }) + } + if (params.waitForStatus === false) { return { txnId: params.prepared.txnId, @@ -1462,11 +1479,14 @@ export class WalletClient { params.statusPolling, ) } catch (error) { + const sdkError = toOmsSdkError(error, WalletOperation.transactionStatus) throw new OmsTransactionError({ operation: WalletOperation.transactionStatus, txnId: params.prepared.txnId, + status: sdkError.status, retryable: true, - cause: error, + upstreamError: sdkError.upstreamError, + cause: sdkError, message: "Transaction was submitted, but status polling failed", }) } diff --git a/src/errors.ts b/src/errors.ts index e244ae7..4ca10d4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,9 +10,18 @@ export type OmsSdkErrorCode = | "OMS_WALLET_SELECTION_STALE" | "OMS_WALLET_SELECTION_UNAVAILABLE" | "OMS_WALLET_SELECTION_IN_FLIGHT" + | "OMS_TRANSACTION_EXECUTION_UNCONFIRMED" | "OMS_TRANSACTION_STATUS_LOOKUP_FAILED" | "OMS_VALIDATION_ERROR" +export interface OmsUpstreamError { + service: "waas" | "indexer" + name?: string + code?: number | string + message?: string + status?: number +} + export interface OmsSdkErrorParams { code: OmsSdkErrorCode message: string @@ -20,6 +29,7 @@ export interface OmsSdkErrorParams { status?: number txnId?: string retryable?: boolean + upstreamError?: OmsUpstreamError cause?: unknown } @@ -29,6 +39,7 @@ export class OmsSdkError extends Error { readonly status?: number readonly txnId?: string readonly retryable?: boolean + readonly upstreamError?: OmsUpstreamError constructor(params: OmsSdkErrorParams) { super(params.message) @@ -38,6 +49,7 @@ export class OmsSdkError extends Error { this.status = params.status this.txnId = params.txnId this.retryable = params.retryable + this.upstreamError = params.upstreamError if (params.cause !== undefined) { this.cause = params.cause } @@ -96,7 +108,11 @@ export function isOmsSdkError(error: unknown): error is OmsSdkError { return error instanceof OmsSdkError } -export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSdkError { +export function toOmsSdkError( + error: unknown, + operation: OmsSdkOperation, + upstreamService: OmsUpstreamError["service"] = "waas", +): OmsSdkError { if (isOmsSdkError(error)) { return error } @@ -104,6 +120,7 @@ export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSd const status = statusFromError(error) const name = error instanceof Error ? error.name : undefined const generatedCode = generatedCodeFromError(error) + const upstreamError = upstreamErrorFromError(error, upstreamService) if (name === "CommitmentConsumed" || generatedCode === 7008) { return new OmsRequestError({ @@ -111,6 +128,7 @@ export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSd operation, status, retryable: false, + upstreamError, cause: error, message: errorMessage(error), }) @@ -123,6 +141,7 @@ export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSd operation, status, retryable: status >= 500, + upstreamError, cause: error, message: errorMessage(error), }) @@ -131,6 +150,7 @@ export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSd return new OmsResponseError({ operation, status, + upstreamError, cause: error, message: errorMessage(error), }) @@ -142,6 +162,7 @@ export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSd operation, status, retryable: status >= 500, + upstreamError, cause: error, message: errorMessage(error), }) @@ -159,6 +180,7 @@ export function toOmsSdkError(error: unknown, operation: OmsSdkOperation): OmsSd operation, status, retryable: name === "WebrpcRequestFailed" || status === undefined || status >= 500, + upstreamError, cause: error, message: errorMessage(error), }) @@ -170,6 +192,11 @@ export function errorMessage(error: unknown): string { function statusFromError(error: unknown): number | undefined { const status = (error as {status?: unknown} | undefined)?.status + const code = generatedCodeFromError(error) + const name = error instanceof Error ? error.name : undefined + if (name === "WebrpcRequestFailed" && code === -1 && status === 400) { + return undefined + } return typeof status === "number" ? status : undefined } @@ -181,3 +208,24 @@ function generatedCodeFromError(error: unknown): number | undefined { function isHttpStatus(status: number | undefined): status is number { return status !== undefined && status >= 400 } + +function upstreamErrorFromError( + error: unknown, + service: OmsUpstreamError["service"], +): OmsUpstreamError | undefined { + const name = error instanceof Error ? error.name : undefined + const code = generatedCodeFromError(error) + const status = statusFromError(error) + + if (!name?.startsWith("Webrpc") && code === undefined && status === undefined) { + return undefined + } + + return { + service, + name, + code, + message: errorMessage(error), + status, + } +} diff --git a/src/index.ts b/src/index.ts index 11d99bc..2d67cde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ export { OmsValidationError, isOmsSdkError, type OmsSdkErrorCode, + type OmsUpstreamError, } from './errors.js' export type { CompleteEmailAuthParams, diff --git a/src/operations.ts b/src/operations.ts index 905e146..1d16633 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -17,6 +17,7 @@ export const WalletOperation = { isValidTypedDataSignature: "wallet.isValidTypedDataSignature", sendTransaction: "wallet.sendTransaction", callContract: "wallet.callContract", + execute: "wallet.execute", getTransactionStatus: "wallet.getTransactionStatus", listAccess: "wallet.listAccess", listAccessPages: "wallet.listAccessPages", diff --git a/tests/errorContracts.test.ts b/tests/errorContracts.test.ts new file mode 100644 index 0000000..3f73afc --- /dev/null +++ b/tests/errorContracts.test.ts @@ -0,0 +1,1683 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; + +import { + LocalStorageManager, + MemoryStorageManager, + Networks, + OMSClient, + OmsRequestError, + OmsSdkError, + SessionStorageManager, + WalletType, + WebCryptoP256CredentialSigner, + isOmsSdkError, + type CredentialSigner, +} from "../src"; + +class MockSigner implements CredentialSigner { + readonly signingAlgorithm = "ecdsa-p256-sha256"; + + constructor(private credential = "0x04" + "11".repeat(64)) {} + + async credentialId(): Promise { + return this.credential; + } + + setCredential(credential: string): void { + this.credential = credential; + } + + async nextNonce(): Promise { + return "42"; + } + + async sign(): Promise { + return "0x" + "22".repeat(64); + } + + async hasCredential(): Promise { + return true; + } +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("public API error contracts", () => { + it("snapshots WaaS transport failures with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed"); + })); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.wallet.startEmailAuth({email: "user@example.com"}), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_REQUEST_FAILED", + "message": "request failed", + "name": "OmsRequestError", + "operation": "wallet.startEmailAuth", + "retryable": true, + "status": null, + "txnId": null, + "upstreamError": { + "code": -1, + "message": "request failed", + "name": "WebrpcRequestFailed", + "service": "waas", + "status": null, + }, + } + `); + }); + + it("snapshots WaaS domain errors with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({verifier: "verifier-1", challenge: "challenge-1"}); + } + if (url.endsWith("/CompleteAuth")) { + return jsonResponse({ + code: 7008, + name: "CommitmentConsumed", + message: "The authentication commitment has already been used", + status: 400, + }, 400); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClient(); + await oms.wallet.startEmailAuth({email: "user@example.com"}); + + await expect(publicError(() => + oms.wallet.completeEmailAuth({code: "123456"}), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_AUTH_COMMITMENT_CONSUMED", + "message": "The authentication commitment has already been used", + "name": "OmsRequestError", + "operation": "wallet.completeEmailAuth", + "retryable": false, + "status": 400, + "txnId": null, + "upstreamError": { + "code": 7008, + "message": "The authentication commitment has already been used", + "name": "CommitmentConsumed", + "service": "waas", + "status": 400, + }, + } + `); + }); + + it("snapshots WaaS HTTP responses with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => + new Response("Bad Gateway", { + status: 502, + headers: {"Content-Type": "text/html"}, + }), + )); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.wallet.startEmailAuth({email: "user@example.com"}), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_HTTP_ERROR", + "message": "bad response", + "name": "OmsRequestError", + "operation": "wallet.startEmailAuth", + "retryable": true, + "status": 502, + "txnId": null, + "upstreamError": { + "code": -5, + "message": "bad response", + "name": "WebrpcBadResponse", + "service": "waas", + "status": 502, + }, + } + `); + }); + + it("snapshots email auth completion local state errors", async () => { + let resolveCompleteAuth: ((response: Response) => void) | undefined; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({verifier: "verifier-1", challenge: "challenge-1"}); + } + if (url.endsWith("/CompleteAuth")) { + return new Promise(resolve => { + resolveCompleteAuth = resolve; + }); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const errors: Array<{label: string; error: SerializedError}> = []; + errors.push({ + label: "wallet.completeEmailAuth.noPendingAuth", + error: await publicError(() => createOmsClient().wallet.completeEmailAuth({code: "123456"})), + }); + + const oms = createOmsClient(); + await oms.wallet.startEmailAuth({email: "user@example.com"}); + const firstCompletion = oms.wallet.completeEmailAuth({code: "123456", walletSelection: "manual"}); + errors.push({ + label: "wallet.completeEmailAuth.inFlightMismatch", + error: await publicError(() => oms.wallet.completeEmailAuth({code: "654321", walletSelection: "manual"})), + }); + + const resolve = await waitForValue(() => resolveCompleteAuth); + resolve(jsonResponse(completeAuthResponse())); + await firstCompletion; + + expect(errors).toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No pending email auth attempt", + "name": "OmsSessionError", + "operation": "wallet.completeEmailAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeEmailAuth.noPendingAuth", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "Email auth completion is already in flight", + "name": "OmsSessionError", + "operation": "wallet.completeEmailAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeEmailAuth.inFlightMismatch", + }, + ] + `); + }); + + it("snapshots pending wallet selection local state errors", async () => { + let resolveUseWallet: ((response: Response) => void) | undefined; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({verifier: "verifier-1", challenge: "challenge-1"}); + } + if (url.endsWith("/CompleteAuth")) { + return jsonResponse(completeAuthResponse()); + } + if (url.endsWith("/UseWallet")) { + return new Promise(resolve => { + resolveUseWallet = resolve; + }); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const errors: Array<{label: string; error: SerializedError}> = []; + + const unavailableOms = createOmsClient(); + await unavailableOms.wallet.startEmailAuth({email: "user@example.com"}); + const unavailableSelection = await unavailableOms.wallet.completeEmailAuth({ + code: "123456", + walletSelection: "manual", + }); + errors.push({ + label: "wallet.pendingWalletSelection.selectWallet.unavailable", + error: await publicError(() => unavailableSelection.selectWallet({walletId: "wallet-missing"})), + }); + + const staleOms = createOmsClient(); + await staleOms.wallet.startEmailAuth({email: "first@example.com"}); + const staleSelection = await staleOms.wallet.completeEmailAuth({ + code: "111111", + walletSelection: "manual", + }); + await staleOms.wallet.startEmailAuth({email: "second@example.com"}); + await staleOms.wallet.completeEmailAuth({ + code: "222222", + walletSelection: "manual", + }); + errors.push({ + label: "wallet.pendingWalletSelection.selectWallet.stale", + error: await publicError(() => staleSelection.selectWallet({walletId: "wallet-1"})), + }); + errors.push({ + label: "wallet.pendingWalletSelection.createAndSelectWallet.stale", + error: await publicError(() => staleSelection.createAndSelectWallet({reference: "stale"})), + }); + + const inFlightOms = createOmsClient(); + await inFlightOms.wallet.startEmailAuth({email: "user@example.com"}); + const inFlightSelection = await inFlightOms.wallet.completeEmailAuth({ + code: "123456", + walletSelection: "manual", + }); + const firstSelection = inFlightSelection.selectWallet({walletId: "wallet-1"}); + errors.push({ + label: "wallet.pendingWalletSelection.selectWallet.inFlight", + error: await publicError(() => inFlightSelection.selectWallet({walletId: "wallet-1"})), + }); + errors.push({ + label: "wallet.pendingWalletSelection.createAndSelectWallet.inFlight", + error: await publicError(() => inFlightSelection.createAndSelectWallet({reference: "fresh"})), + }); + + const resolve = await waitForValue(() => resolveUseWallet); + resolve(jsonResponse({wallet: testWallet()})); + await firstSelection; + + expect(errors).toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_WALLET_SELECTION_UNAVAILABLE", + "message": "Selected wallet is not one of the available options", + "name": "OmsWalletSelectionError", + "operation": "wallet.pendingWalletSelection.selectWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.pendingWalletSelection.selectWallet.unavailable", + }, + { + "error": { + "code": "OMS_WALLET_SELECTION_STALE", + "message": "Pending wallet selection is no longer active", + "name": "OmsWalletSelectionError", + "operation": "wallet.pendingWalletSelection.selectWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.pendingWalletSelection.selectWallet.stale", + }, + { + "error": { + "code": "OMS_WALLET_SELECTION_STALE", + "message": "Pending wallet selection is no longer active", + "name": "OmsWalletSelectionError", + "operation": "wallet.pendingWalletSelection.createAndSelectWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.pendingWalletSelection.createAndSelectWallet.stale", + }, + { + "error": { + "code": "OMS_WALLET_SELECTION_IN_FLIGHT", + "message": "Pending wallet selection already has an action in flight", + "name": "OmsWalletSelectionError", + "operation": "wallet.pendingWalletSelection.selectWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.pendingWalletSelection.selectWallet.inFlight", + }, + { + "error": { + "code": "OMS_WALLET_SELECTION_IN_FLIGHT", + "message": "Pending wallet selection already has an action in flight", + "name": "OmsWalletSelectionError", + "operation": "wallet.pendingWalletSelection.createAndSelectWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.pendingWalletSelection.createAndSelectWallet.inFlight", + }, + ] + `); + }); + + it("snapshots SDK-local errors without upstream details", async () => { + const oms = createOmsClient(); + + await expect(publicError(() => + oms.wallet.signMessage({network: Networks.polygon, message: "hello"}), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.signMessage", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + } + `); + }); + + it("snapshots missing-session contracts for protected wallet methods", async () => { + const oms = createOmsClient(); + + await expect(publicErrors([ + ["wallet.listWallets", () => oms.wallet.listWallets()], + ["wallet.useWallet", () => oms.wallet.useWallet({walletId: "wallet-1"})], + ["wallet.createWallet", () => oms.wallet.createWallet()], + ["wallet.getIdToken", () => oms.wallet.getIdToken()], + ["wallet.signTypedData", () => oms.wallet.signTypedData({ + network: Networks.polygon, + typedData: {message: "hello"}, + })], + ["wallet.sendTransaction", () => oms.wallet.sendTransaction({ + network: Networks.polygon, + to: "0x2222222222222222222222222222222222222222", + value: 1n, + })], + ["wallet.callContract", () => oms.wallet.callContract({ + network: Networks.polygon, + contractAddress: "0x2222222222222222222222222222222222222222", + method: "transfer(address,uint256)", + args: [ + {type: "address", value: "0x3333333333333333333333333333333333333333"}, + {type: "uint256", value: "1"}, + ], + })], + ["wallet.listAccess", () => oms.wallet.listAccess()], + ["wallet.listAccessPages", () => iterateAccessPages(oms.wallet.listAccessPages())], + ["wallet.revokeAccess", () => oms.wallet.revokeAccess({targetCredentialId: "credential-1"})], + ])).resolves.toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No authenticated wallet session", + "name": "OmsSessionError", + "operation": "wallet.listWallets", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.listWallets", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.useWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.useWallet", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.createWallet", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.createWallet", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.getIdToken", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.getIdToken", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.signTypedData", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.signTypedData", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.sendTransaction", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.sendTransaction", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.callContract", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.callContract", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.listAccess", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.listAccess", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.listAccessPages", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.listAccessPages", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "No active wallet session", + "name": "OmsSessionError", + "operation": "wallet.revokeAccess", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.revokeAccess", + }, + ] + `); + }); + + it("snapshots OIDC local error contracts without upstream details", async () => { + const oms = createOmsClient(); + const omsWithoutRedirectStorage = createOmsClient({redirectAuthStorage: null}); + + await expect(publicErrors([ + ["wallet.startOidcRedirectAuth.unknownProvider", () => oms.wallet.startOidcRedirectAuth({ + provider: "github", + redirectUri: "https://app.example/auth/callback", + })], + ["wallet.startOidcRedirectAuth.missingRedirectStorage", () => omsWithoutRedirectStorage.wallet.startOidcRedirectAuth({ + provider: testOidcProvider(), + redirectUri: "https://app.example/auth/callback", + })], + ["wallet.completeOidcRedirectAuth.missingCallbackParams", () => oms.wallet.completeOidcRedirectAuth({ + callbackUrl: "https://app.example/auth/callback", + })], + ["wallet.completeOidcRedirectAuth.providerError", () => oms.wallet.completeOidcRedirectAuth({ + callbackUrl: "https://app.example/auth/callback?error=access_denied&error_description=User%20cancelled", + })], + ["wallet.completeOidcRedirectAuth.noPendingAuth", () => oms.wallet.completeOidcRedirectAuth({ + callbackUrl: "https://app.example/auth/callback?code=code-1&state=state-1", + })], + ["wallet.completeOidcRedirectAuth.cleanUrlWithoutBrowser", () => oms.wallet.completeOidcRedirectAuth({ + callbackUrl: "https://app.example/auth/callback?code=code-1&state=state-1", + cleanUrl: true, + })], + ["wallet.signInWithOidcRedirect.missingCurrentUrl", () => oms.wallet.signInWithOidcRedirect({ + provider: testOidcProvider(), + })], + ])).resolves.toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "OIDC provider "github" is not configured", + "name": "OmsValidationError", + "operation": "wallet.startOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.startOidcRedirectAuth.unknownProvider", + }, + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "OIDC redirect auth requires redirectAuthStorage or browser sessionStorage", + "name": "OmsValidationError", + "operation": "wallet.startOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.startOidcRedirectAuth.missingRedirectStorage", + }, + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "OIDC callback URL is missing code or state", + "name": "OmsValidationError", + "operation": "wallet.completeOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeOidcRedirectAuth.missingCallbackParams", + }, + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "User cancelled", + "name": "OmsValidationError", + "operation": "wallet.completeOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeOidcRedirectAuth.providerError", + }, + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "No pending OIDC redirect auth found", + "name": "OmsValidationError", + "operation": "wallet.completeOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeOidcRedirectAuth.noPendingAuth", + }, + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "cleanUrl requires replaceUrl or browser history support", + "name": "OmsValidationError", + "operation": "wallet.completeOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeOidcRedirectAuth.cleanUrlWithoutBrowser", + }, + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "signInWithOidcRedirect requires currentUrl outside a browser", + "name": "OmsValidationError", + "operation": "wallet.signInWithOidcRedirect", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.signInWithOidcRedirect.missingCurrentUrl", + }, + ] + `); + }); + + it("snapshots OIDC redirect real-flow local mismatch errors", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({verifier: "verifier-1", challenge: "challenge-1"}); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const errors: Array<{label: string; error: SerializedError}> = []; + + const nonceOms = createOmsClient({redirectAuthStorage: new MemoryStorageManager()}); + await nonceOms.wallet.startOidcRedirectAuth({ + provider: testOidcProvider(), + redirectUri: "https://app.example/auth/callback", + }); + errors.push({ + label: "wallet.completeOidcRedirectAuth.nonceMismatch", + error: await publicError(() => nonceOms.wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${encodeTestOidcState({ + nonce: "bad-nonce", + scope: "project-id", + })}`, + })), + }); + + const signer = new MockSigner(); + const signerOms = createOmsClient({ + credentialSigner: signer, + redirectAuthStorage: new MemoryStorageManager(), + }); + const started = await signerOms.wallet.startOidcRedirectAuth({ + provider: testOidcProvider(), + redirectUri: "https://app.example/auth/callback", + }); + signer.setCredential("0x04" + "99".repeat(64)); + errors.push({ + label: "wallet.completeOidcRedirectAuth.signerMismatch", + error: await publicError(() => signerOms.wallet.completeOidcRedirectAuth({ + callbackUrl: `https://app.example/auth/callback?code=auth-code&state=${started.state}`, + })), + }); + + expect(errors).toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_VALIDATION_ERROR", + "message": "OIDC state nonce mismatch", + "name": "OmsValidationError", + "operation": "wallet.completeOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeOidcRedirectAuth.nonceMismatch", + }, + { + "error": { + "code": "OMS_SESSION_MISSING", + "message": "OIDC redirect auth signer mismatch", + "name": "OmsSessionError", + "operation": "wallet.completeOidcRedirectAuth", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "wallet.completeOidcRedirectAuth.signerMismatch", + }, + ] + `); + }); + + it("snapshots signInWithOidcRedirect missing assignUrl after real redirect start", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/CommitVerifier")) { + return jsonResponse({verifier: "verifier-1", challenge: "challenge-1"}); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.wallet.signInWithOidcRedirect({ + provider: testOidcProvider(), + currentUrl: "https://app.example/login", + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_VALIDATION_ERROR", + "message": "signInWithOidcRedirect requires assignUrl outside a browser", + "name": "OmsValidationError", + "operation": "wallet.signInWithOidcRedirect", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + } + `); + }); + + it("snapshots signature validation backend failures with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed"); + })); + + const oms = createOmsClientWithSession(); + + await expect(publicErrors([ + ["wallet.isValidMessageSignature", () => oms.wallet.isValidMessageSignature({ + network: Networks.polygon, + message: "hello", + signature: "0xmessage", + })], + ["wallet.isValidTypedDataSignature", () => oms.wallet.isValidTypedDataSignature({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + typedData: { + domain: {name: "Test", chainId: 137n}, + types: {Message: [{name: "contents", type: "string"}]}, + message: {contents: "hello"}, + primaryType: "Message", + }, + signature: "0xtyped", + })], + ])).resolves.toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_REQUEST_FAILED", + "message": "request failed", + "name": "OmsRequestError", + "operation": "wallet.isValidMessageSignature", + "retryable": true, + "status": null, + "txnId": null, + "upstreamError": { + "code": -1, + "message": "request failed", + "name": "WebrpcRequestFailed", + "service": "waas", + "status": null, + }, + }, + "label": "wallet.isValidMessageSignature", + }, + { + "error": { + "code": "OMS_REQUEST_FAILED", + "message": "request failed", + "name": "OmsRequestError", + "operation": "wallet.isValidTypedDataSignature", + "retryable": true, + "status": null, + "txnId": null, + "upstreamError": { + "code": -1, + "message": "request failed", + "name": "WebrpcRequestFailed", + "service": "waas", + "status": null, + }, + }, + "label": "wallet.isValidTypedDataSignature", + }, + ] + `); + }); + + it("snapshots direct transaction status backend errors with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/TransactionStatus")) { + return jsonResponse({ + code: 7308, + name: "TransactionNotFound", + message: "Transaction not found", + status: 404, + }, 404); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.wallet.getTransactionStatus({txnId: "txn-missing"}), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_REQUEST_FAILED", + "message": "Transaction not found", + "name": "OmsRequestError", + "operation": "wallet.getTransactionStatus", + "retryable": false, + "status": 404, + "txnId": null, + "upstreamError": { + "code": 7308, + "message": "Transaction not found", + "name": "TransactionNotFound", + "service": "waas", + "status": 404, + }, + } + `); + }); + + it("snapshots transaction local validation errors without upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-no-fee-options", + status: "quoted", + feeOptions: [], + sponsored: false, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClientWithSession(); + + await expect(publicError(() => + oms.wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + waitForStatus: false, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_VALIDATION_ERROR", + "message": "No fee options available for unsponsored transaction", + "name": "OmsValidationError", + "operation": "wallet.sendTransaction", + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + } + `); + }); + + it("snapshots transaction execute failures as unconfirmed writes", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-execute", + status: "quoted", + feeOptions: [], + sponsored: true, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + if (url.endsWith("/Execute")) { + throw new TypeError("fetch failed"); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClientWithSession(); + + await expect(publicError(() => + oms.wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_TRANSACTION_EXECUTION_UNCONFIRMED", + "message": "Transaction execution failed before status could be confirmed", + "name": "OmsTransactionError", + "operation": "wallet.execute", + "retryable": false, + "status": null, + "txnId": "txn-execute", + "upstreamError": { + "code": -1, + "message": "request failed", + "name": "WebrpcRequestFailed", + "service": "waas", + "status": null, + }, + } + `); + }); + + it("snapshots transaction status polling failures with txn and upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-1", + status: "quoted", + feeOptions: [], + sponsored: true, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + if (url.endsWith("/Execute")) { + return jsonResponse({status: "pending"}); + } + if (url.endsWith("/TransactionStatus")) { + throw new TypeError("fetch failed"); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClientWithSession(); + + await expect(publicError(() => + oms.wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_TRANSACTION_STATUS_LOOKUP_FAILED", + "message": "Transaction was submitted, but status polling failed", + "name": "OmsTransactionError", + "operation": "wallet.transactionStatus", + "retryable": true, + "status": null, + "txnId": "txn-1", + "upstreamError": { + "code": -1, + "message": "request failed", + "name": "WebrpcRequestFailed", + "service": "waas", + "status": null, + }, + } + `); + }); + + it("snapshots transaction status polling backend errors as retryable", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/PrepareEthereumTransaction")) { + return jsonResponse({ + txnId: "txn-404", + status: "quoted", + feeOptions: [], + sponsored: true, + expiresAt: "2099-01-01T00:00:00Z", + }); + } + if (url.endsWith("/Execute")) { + return jsonResponse({status: "pending"}); + } + if (url.endsWith("/TransactionStatus")) { + return jsonResponse({ + code: 7308, + name: "TransactionNotFound", + message: "Transaction not found", + status: 404, + }, 404); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClientWithSession(); + + await expect(publicError(() => + oms.wallet.sendTransaction({ + network: Networks.polygon, + to: "0x1111111111111111111111111111111111111111", + value: 0n, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_TRANSACTION_STATUS_LOOKUP_FAILED", + "message": "Transaction was submitted, but status polling failed", + "name": "OmsTransactionError", + "operation": "wallet.transactionStatus", + "retryable": true, + "status": 404, + "txnId": "txn-404", + "upstreamError": { + "code": 7308, + "message": "Transaction not found", + "name": "TransactionNotFound", + "service": "waas", + "status": 404, + }, + } + `); + }); + + it("snapshots access backend errors with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/ListAccess") || url.endsWith("/RevokeAccess")) { + return jsonResponse({ + code: 7207, + name: "Unauthorized", + message: "Unauthorized", + status: 401, + }, 401); + } + + throw new Error(`Unexpected request: ${url}`); + })); + + const oms = createOmsClientWithSession(); + + await expect(publicErrors([ + ["wallet.listAccess", () => oms.wallet.listAccess()], + ["wallet.listAccessPages", () => iterateAccessPages(oms.wallet.listAccessPages())], + ["wallet.revokeAccess", () => oms.wallet.revokeAccess({targetCredentialId: "credential-1"})], + ])).resolves.toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_REQUEST_FAILED", + "message": "Unauthorized", + "name": "OmsRequestError", + "operation": "wallet.listAccess", + "retryable": false, + "status": 401, + "txnId": null, + "upstreamError": { + "code": 7207, + "message": "Unauthorized", + "name": "Unauthorized", + "service": "waas", + "status": 401, + }, + }, + "label": "wallet.listAccess", + }, + { + "error": { + "code": "OMS_REQUEST_FAILED", + "message": "Unauthorized", + "name": "OmsRequestError", + "operation": "wallet.listAccessPages", + "retryable": false, + "status": 401, + "txnId": null, + "upstreamError": { + "code": 7207, + "message": "Unauthorized", + "name": "Unauthorized", + "service": "waas", + "status": 401, + }, + }, + "label": "wallet.listAccessPages", + }, + { + "error": { + "code": "OMS_REQUEST_FAILED", + "message": "Unauthorized", + "name": "OmsRequestError", + "operation": "wallet.revokeAccess", + "retryable": false, + "status": 401, + "txnId": null, + "upstreamError": { + "code": 7207, + "message": "Unauthorized", + "name": "Unauthorized", + "service": "waas", + "status": 401, + }, + }, + "label": "wallet.revokeAccess", + }, + ] + `); + }); + + it("snapshots indexer backend errors with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ + error: "Unavailable", + code: "INDEXER_UNAVAILABLE", + message: "Indexer is unavailable", + }, 503))); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.indexer.getTokenBalances({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + includeMetadata: false, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_HTTP_ERROR", + "message": "Indexer is unavailable", + "name": "OmsRequestError", + "operation": "indexer.getTokenBalances", + "retryable": true, + "status": 503, + "txnId": null, + "upstreamError": { + "code": "INDEXER_UNAVAILABLE", + "message": "Indexer is unavailable", + "name": "Unavailable", + "service": "indexer", + "status": 503, + }, + } + `); + }); + + it("snapshots indexer non-JSON HTTP errors without raw upstream bodies", async () => { + vi.stubGlobal("fetch", vi.fn(async () => + new Response("Bad Gateway", { + status: 502, + headers: {"Content-Type": "text/html"}, + }), + )); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.indexer.getTokenBalances({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + includeMetadata: false, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_HTTP_ERROR", + "message": "indexer.getTokenBalances failed with HTTP 502", + "name": "OmsRequestError", + "operation": "indexer.getTokenBalances", + "retryable": true, + "status": 502, + "txnId": null, + "upstreamError": { + "code": null, + "message": "indexer.getTokenBalances failed with HTTP 502", + "name": null, + "service": "indexer", + "status": 502, + }, + } + `); + }); + + it("snapshots native balance indexer errors with upstream details", async () => { + let callCount = 0; + vi.stubGlobal("fetch", vi.fn(async () => { + callCount += 1; + if (callCount === 1) { + return jsonResponse({ + error: "Unavailable", + code: "INDEXER_UNAVAILABLE", + message: "Indexer is unavailable", + }, 503); + } + if (callCount === 2) { + throw new TypeError("fetch failed"); + } + return new Response("not-json", {status: 200}); + })); + + const oms = createOmsClient(); + + await expect(publicErrors([ + ["indexer.getNativeTokenBalance.http", () => oms.indexer.getNativeTokenBalance({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + })], + ["indexer.getNativeTokenBalance.transport", () => oms.indexer.getNativeTokenBalance({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + })], + ["indexer.getNativeTokenBalance.malformed", () => oms.indexer.getNativeTokenBalance({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + })], + ])).resolves.toMatchInlineSnapshot(` + [ + { + "error": { + "code": "OMS_HTTP_ERROR", + "message": "Indexer is unavailable", + "name": "OmsRequestError", + "operation": "indexer.getNativeTokenBalance", + "retryable": true, + "status": 503, + "txnId": null, + "upstreamError": { + "code": "INDEXER_UNAVAILABLE", + "message": "Indexer is unavailable", + "name": "Unavailable", + "service": "indexer", + "status": 503, + }, + }, + "label": "indexer.getNativeTokenBalance.http", + }, + { + "error": { + "code": "OMS_REQUEST_FAILED", + "message": "fetch failed", + "name": "OmsRequestError", + "operation": "indexer.getNativeTokenBalance", + "retryable": true, + "status": null, + "txnId": null, + "upstreamError": { + "code": null, + "message": "fetch failed", + "name": "TypeError", + "service": "indexer", + "status": null, + }, + }, + "label": "indexer.getNativeTokenBalance.transport", + }, + { + "error": { + "code": "OMS_INVALID_RESPONSE", + "message": "Invalid JSON response from indexer.getNativeTokenBalance", + "name": "OmsResponseError", + "operation": "indexer.getNativeTokenBalance", + "retryable": null, + "status": 200, + "txnId": null, + "upstreamError": { + "code": null, + "message": "Invalid JSON response from indexer.getNativeTokenBalance", + "name": null, + "service": "indexer", + "status": 200, + }, + }, + "label": "indexer.getNativeTokenBalance.malformed", + }, + ] + `); + }); + + it("snapshots indexer transport failures with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("fetch failed"); + })); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.indexer.getTokenBalances({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + includeMetadata: false, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_REQUEST_FAILED", + "message": "fetch failed", + "name": "OmsRequestError", + "operation": "indexer.getTokenBalances", + "retryable": true, + "status": null, + "txnId": null, + "upstreamError": { + "code": null, + "message": "fetch failed", + "name": "TypeError", + "service": "indexer", + "status": null, + }, + } + `); + }); + + it("snapshots indexer malformed response errors with upstream details", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("not-json", {status: 200}))); + + const oms = createOmsClient(); + + await expect(publicError(() => + oms.indexer.getTokenBalances({ + network: Networks.polygon, + walletAddress: "0x9999999999999999999999999999999999999999", + includeMetadata: false, + }), + )).resolves.toMatchInlineSnapshot(` + { + "code": "OMS_INVALID_RESPONSE", + "message": "Invalid JSON response from indexer.getTokenBalances", + "name": "OmsResponseError", + "operation": "indexer.getTokenBalances", + "retryable": null, + "status": 200, + "txnId": null, + "upstreamError": { + "code": null, + "message": "Invalid JSON response from indexer.getTokenBalances", + "name": null, + "service": "indexer", + "status": 200, + }, + } + `); + }); + + it("snapshots exported storage and signer runtime errors", async () => { + vi.stubGlobal("localStorage", undefined); + vi.stubGlobal("sessionStorage", undefined); + + await expect(publicErrors([ + ["LocalStorageManager.get", () => Promise.resolve(new LocalStorageManager().get("key"))], + ["SessionStorageManager.get", () => Promise.resolve(new SessionStorageManager().get("key"))], + ])).resolves.toMatchInlineSnapshot(` + [ + { + "error": { + "code": null, + "message": "LocalStorageManager requires globalThis.localStorage", + "name": "Error", + "operation": null, + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "LocalStorageManager.get", + }, + { + "error": { + "code": null, + "message": "SessionStorageManager requires globalThis.sessionStorage", + "name": "Error", + "operation": null, + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + }, + "label": "SessionStorageManager.get", + }, + ] + `); + + vi.stubGlobal("crypto", undefined); + await expect(publicError(() => + new WebCryptoP256CredentialSigner().credentialId(), + )).resolves.toMatchInlineSnapshot(` + { + "code": null, + "message": "WebCrypto SubtleCrypto is required for the default OMS credential signer", + "name": "Error", + "operation": null, + "retryable": null, + "status": null, + "txnId": null, + "upstreamError": null, + } + `); + }); + + it("snapshots exported error helper and subclass fields", () => { + const error = new OmsRequestError({ + code: "OMS_HTTP_ERROR", + operation: "wallet.startEmailAuth", + message: "bad gateway", + status: 502, + retryable: true, + upstreamError: { + service: "waas", + name: "WebrpcBadResponse", + code: -5, + message: "bad response", + status: 502, + }, + }); + + expect(isOmsSdkError(error)).toBe(true); + expect(isOmsSdkError(new Error("plain"))).toBe(false); + expect(error).toBeInstanceOf(OmsSdkError); + expect(serializeError(error)).toMatchInlineSnapshot(` + { + "code": "OMS_HTTP_ERROR", + "message": "bad gateway", + "name": "OmsRequestError", + "operation": "wallet.startEmailAuth", + "retryable": true, + "status": 502, + "txnId": null, + "upstreamError": { + "code": -5, + "message": "bad response", + "name": "WebrpcBadResponse", + "service": "waas", + "status": 502, + }, + } + `); + }); +}); + +async function publicError(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return serializeError(error); + } + + throw new Error("Expected public API call to reject"); +} + +async function publicErrors( + cases: Array<[label: string, action: () => Promise]>, +): Promise> { + const errors: Array<{label: string; error: SerializedError}> = []; + for (const [label, action] of cases) { + errors.push({ + label, + error: await publicError(action), + }); + } + return errors; +} + +async function iterateAccessPages(pages: AsyncIterable): Promise { + for await (const _page of pages) { + return; + } +} + +async function waitForValue(read: () => T | undefined): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const value = read(); + if (value !== undefined) { + return value; + } + await new Promise(resolve => setTimeout(resolve, 0)); + } + + throw new Error("Timed out waiting for async test fixture"); +} + +function serializeError(error: unknown): SerializedError { + const value = error as { + name?: unknown + code?: unknown + operation?: unknown + status?: unknown + retryable?: unknown + txnId?: unknown + upstreamError?: unknown + }; + return { + name: error instanceof Error ? error.name : stringOrNull(value.name), + code: stringOrNull(value.code), + operation: stringOrNull(value.operation), + message: error instanceof Error ? error.message : String(error), + status: numberOrNull(value.status), + retryable: booleanOrNull(value.retryable), + txnId: stringOrNull(value.txnId), + upstreamError: serializeUpstreamError(value.upstreamError), + }; +} + +function serializeUpstreamError(error: unknown): SerializedUpstreamError | null { + if (!error || typeof error !== "object") { + return null; + } + + const value = error as { + service?: unknown + name?: unknown + code?: unknown + message?: unknown + status?: unknown + }; + return { + service: stringOrNull(value.service), + name: stringOrNull(value.name), + code: numberOrStringOrNull(value.code), + message: stringOrNull(value.message), + status: numberOrNull(value.status), + }; +} + +function createOmsClient(params: { + credentialSigner?: CredentialSigner + redirectAuthStorage?: MemoryStorageManager | null +} = {}): OMSClient { + const clientParams: ConstructorParameters[0] = { + publishableKey: "publishable-key", + projectId: "project-id", + storage: new MemoryStorageManager(), + credentialSigner: params.credentialSigner ?? new MockSigner(), + environment: { + walletApiUrl: "https://wallet.example", + indexerUrlTemplate: "https://indexer.example/{value}", + }, + }; + + if (params.redirectAuthStorage !== null) { + clientParams.redirectAuthStorage = params.redirectAuthStorage ?? new MemoryStorageManager(); + } + + return new OMSClient(clientParams); +} + +function createOmsClientWithSession(): OMSClient { + const oms = createOmsClient(); + (oms.wallet as any).persistSession( + "wallet-id", + "0x9999999999999999999999999999999999999999", + ); + return oms; +} + +function testOidcProvider() { + return { + clientId: "client-id", + issuer: "https://issuer.example", + authorizationUrl: "https://issuer.example/oauth/authorize", + }; +} + +function completeAuthResponse() { + return { + identity: {type: "email", sub: "user@example.com"}, + wallets: [testWallet()], + credential: testCredential(), + email: "user@example.com", + }; +} + +function testWallet(id = "wallet-1", addressByte = "11") { + return { + id, + type: WalletType.Ethereum, + address: `0x${addressByte.repeat(20)}`, + }; +} + +function testCredential() { + return { + credentialId: "0x04" + "11".repeat(64), + expiresAt: "2099-01-01T00:00:00Z", + isCaller: true, + }; +} + +function encodeTestOidcState(payload: {nonce: string; scope: string; redirect_uri?: string}): string { + let binary = ""; + for (const byte of new TextEncoder().encode(JSON.stringify(payload))) { + binary += String.fromCharCode(byte); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: {"Content-Type": "application/json"}, + }); +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" ? value : null; +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function numberOrStringOrNull(value: unknown): number | string | null { + return typeof value === "number" || typeof value === "string" ? value : null; +} + +interface SerializedError { + name: string | null + code: string | null + operation: string | null + message: string + status: number | null + retryable: boolean | null + txnId: string | null + upstreamError: SerializedUpstreamError | null +} + +interface SerializedUpstreamError { + service: string | null + name: string | null + code: number | string | null + message: string | null + status: number | null +} diff --git a/type-tests/oidcProviderTypes.ts b/type-tests/oidcProviderTypes.ts index e8101e9..6d2e6f2 100644 --- a/type-tests/oidcProviderTypes.ts +++ b/type-tests/oidcProviderTypes.ts @@ -10,6 +10,9 @@ import { type OMSClientSessionLoginType, type OMSClientSessionExpiredListener, type OMSClientSessionState, + type OmsSdkError, + type OmsSdkErrorCode, + type OmsUpstreamError, type TokenBalance, type TokenBalancesPage, type TokenBalancesResult, @@ -109,6 +112,16 @@ const tokenBalance: TokenBalance = { }; const tokenBalancesPage: TokenBalancesPage = {page: 0, pageSize: 40, more: false}; const tokenBalancesResult: TokenBalancesResult = {status: 200, page: tokenBalancesPage, balances: [tokenBalance]}; +const upstreamError: OmsUpstreamError = { + service: "waas", + name: "CommitmentConsumed", + code: 7008, + message: "The authentication commitment has already been used", + status: 400, +}; +const sdkError = undefined as unknown as OmsSdkError; +const maybeUpstreamError: OmsUpstreamError | undefined = sdkError.upstreamError; +const transactionExecutionCode: OmsSdkErrorCode = "OMS_TRANSACTION_EXECUTION_UNCONFIRMED"; void session; void unsubscribeSessionExpired; void loginType; @@ -119,6 +132,9 @@ void allNetworks; void tokenContractInfo; void tokenMetadata; void tokenBalancesResult; +void upstreamError; +void maybeUpstreamError; +void transactionExecutionCode; void defaultClient.supportedNetworks; // @ts-expect-error findNetworkById accepts numeric chain IDs only. findNetworkById("80002");