diff --git a/.agents/skills/react-doctor/SKILL.md b/.agents/skills/react-doctor/SKILL.md new file mode 100644 index 0000000..d9e4c46 --- /dev/null +++ b/.agents/skills/react-doctor/SKILL.md @@ -0,0 +1,56 @@ +--- +name: react-doctor +description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. +version: "1.2.0" +--- + +# React Doctor + +Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. + +## After making React code changes: + +Run `npx react-doctor@latest --verbose --scope changed` and check the score did not regress. + +If the score dropped, fix the regressions before committing. + +## For general cleanup or code improvement: + +Run `npx react-doctor@latest --verbose` (the default `--scope full`) to scan the full codebase. Fix issues by severity — errors first, then warnings. + +## For a focused UI design audit: + +Run `npx react-doctor@latest design --verbose`. This selects only design-tagged UI composition, typography, interaction, accessibility, and motion rules, including focused rules that remain opt-in during a general health scan. + +## /doctor — full local triage workflow + +When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: + +```bash +curl --fail --silent --show-error \ + --header 'Cache-Control: no-cache' \ + https://www.react.doctor/prompts/react-doctor-agent.md +``` + +The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. + +Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. + +## Configuring or explaining rules + +When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). + +## Command + +```bash +npx react-doctor@latest --verbose --scope changed +``` + +| Flag | Purpose | +| ----------------- | ---------------------------------------------------------------- | +| `.` | Scan current directory | +| `--verbose` | Show affected files and line numbers per rule | +| `--scope changed` | Only report issues introduced vs the base branch (default: full) | +| `--scope lines` | Only report issues on the changed lines | +| `--score` | Output only the numeric score | +| `design` | Run only the focused UI design diagnostics | diff --git a/.agents/skills/react-doctor/references/explain.md b/.agents/skills/react-doctor/references/explain.md new file mode 100644 index 0000000..c3a1008 --- /dev/null +++ b/.agents/skills/react-doctor/references/explain.md @@ -0,0 +1,73 @@ +# Explaining and configuring rules + +Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user +wants to understand a rule or change which rules run — not for fixing diagnostics +(that is the main `react-doctor` skill / `/doctor`). + +Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off", +"stop flagging X", "too noisy", "disable design rules". + +## Workflow + +1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`). +2. Explain it before changing anything: + +```bash +npx react-doctor@latest rules explain react-doctor/no-array-index-as-key +``` + +3. Pick the narrowest control that matches the user's intent (see decision guide). +4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting). +5. Validate the change did what they wanted: + +```bash +npx react-doctor@latest --verbose --diff +``` + +## Commands + +```bash +npx react-doctor@latest rules list # every rule + its effective severity +npx react-doctor@latest rules list --configured # only what your config changed +npx react-doctor@latest rules list --category Performance # filter by category +npx react-doctor@latest rules explain # why it matters + how to configure +npx react-doctor@latest rules disable # rule never runs +npx react-doctor@latest rules enable # turn back on at its recommended severity +npx react-doctor@latest rules set warn # off | warn | error +npx react-doctor@latest rules category "React Native" off # whole category +npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) +npx react-doctor@latest rules unignore-tag design +``` + +Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`). + +## Decision guide + +Match the control to the intent — prefer the narrowest one: + +- **User disagrees with one rule / it's a false positive for them** → `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". +- **Rule is fine but wrong severity** → `rules set warn` or `rules set error`. +- **A disabled-by-default rule they want on** → `rules enable `. +- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "" off`. +- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag `. +- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. +- **Restore test or story findings to production health** → set `surfaces.score.includeFileContexts` or `surfaces.ciFailure.includeFileContexts` to `["test"]`, `["story"]`, or both. Other surface exclusions still apply. + +How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. + +## Config shape + +Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`: + +```ts +// doctor.config.ts +export default { + rules: { "react-doctor/no-array-index-as-key": "off" }, + categories: { "React Native": "warn" }, + ignore: { tags: ["design"] }, +}; +``` + +## Educating the user + +When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues. diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..adf157b --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,5 @@ +# Changesets + +OWS publishable packages (`@1shotapi/ows-*`) are versioned independently via [Changesets](https://github.com/changesets/changesets). + +Example apps are excluded. Maintainers: `npm run changeset` → `npm run version-packages` → `npm run release`. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..e6b03d7 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@1shotapi/ows-onchain", + "@1shotapi/ows-example-general-wallet", + "@1shotapi/ows-example-host", + "@1shotapi/ows-example-credential-issuer", + "@1shotapi/ows-example-credential-verifier" + ] +} diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..bd4303d --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "command": "node .cursor/hooks/react-doctor.mjs", + "matcher": "Write|Edit|MultiEdit|ApplyPatch", + "timeout": 120 + } + ] + } +} diff --git a/.cursor/hooks/react-doctor.mjs b/.cursor/hooks/react-doctor.mjs new file mode 100644 index 0000000..de0a26c --- /dev/null +++ b/.cursor/hooks/react-doctor.mjs @@ -0,0 +1,153 @@ +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// --verbose scans on large diffs can exceed spawnSync's 1 MiB default. +const SPAWN_MAX_BUFFER_BYTES = 16 * 1024 * 1024; + +const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'ApplyPatch']); + +const readFileOrEmpty = (filePath) => { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return ''; + } +}; + +/** + * Consume hook JSON from stdin without `readFileSync(0)`, which blocks until + * EOF and can hang the hook when stdin is not a ready pipe. + */ +const readStdin = async () => { + if (process.stdin.isTTY) { + return ''; + } + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const hookEventName = (input) => + input.hook_event_name || input.eventName || input.event_name; + +const shouldScan = (input) => { + const eventName = hookEventName(input); + if (eventName === 'PostToolBatch') { + const toolCalls = Array.isArray(input.tool_calls) ? input.tool_calls : []; + return toolCalls.some((toolCall) => EDIT_TOOL_NAMES.has(toolCall.tool_name)); + } + const toolName = input.tool_name || input.toolName || input.tool; + // hooks.json matcher already limits invocation. If the payload omits toolName, + // scan conservatively rather than silently skipping. If toolName is present + // but not an edit tool, skip. + if (toolName == null || toolName === '') { + return true; + } + return EDIT_TOOL_NAMES.has(toolName); +}; + +const runReactDoctor = (outputPath) => { + // Each candidate is a single shell command string (not an args array): + // `shell: true` is required to run the Windows `.cmd` shims, and an args + // array with `shell: true` trips Node's DEP0190. A missing command exits + // 127 via a POSIX shell (no ENOENT error) and 9009 via cmd.exe, so fall + // through on those. The local bin is probed with existsSync (its `./` + // prefix form is not runnable by cmd.exe at all). Returns `null` when no + // runner is available (distinct from exit 0 = clean scan). + const localBin = process.platform === 'win32' + ? 'node_modules\\.bin\\react-doctor.cmd' + : './node_modules/.bin/react-doctor'; + const commands = [ + ...(existsSync(localBin) + ? [localBin + ' --verbose --scope changed --blocking warning --no-score'] + : []), + 'react-doctor --verbose --scope changed --blocking warning --no-score', + 'pnpm dlx react-doctor@latest --verbose --scope changed --blocking warning --no-score', + 'npx --yes react-doctor@latest --verbose --scope changed --blocking warning --no-score', + ]; + + for (const command of commands) { + const result = spawnSync(command, { encoding: 'utf8', shell: true, maxBuffer: SPAWN_MAX_BUFFER_BYTES }); + if (result.error?.code === 'ENOENT' || result.status === 127 || result.status === 9009) continue; + try { + writeFileSync(outputPath, (result.stdout || '') + (result.stderr || '')); + } catch {} + return result.status ?? 1; + } + + return null; +}; + +const cleanup = (...paths) => { + for (const path of paths) { + try { unlinkSync(path); } catch {} + } +}; + +const fail = (error) => { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + // stderr only — stdout is reserved for hook JSON the agent parses. + console.error(`[react-doctor hook] ${message}`); + process.exit(1); +}; + +const main = async () => { + let input; + try { + input = JSON.parse((await readStdin()) || '{}'); + } catch (error) { + fail(error instanceof Error ? error : new Error(`invalid hook stdin JSON: ${String(error)}`)); + } + + if (!shouldScan(input)) { + process.exit(0); + } + + const projectRoot = process.env.CLAUDE_PROJECT_DIR || join(__dirname, '../..'); + const outputPath = join(tmpdir(), `react-doctor-agent-hook-output-${process.pid}.txt`); + + try { + process.chdir(projectRoot); + } catch (error) { + fail(error); + } + + const scanResult = runReactDoctor(outputPath); + if (scanResult === null) { + // Soft skip: do not block the agent, but do not pretend a clean scan ran. + cleanup(outputPath); + console.error('[react-doctor hook] react-doctor not found; skipping scan'); + process.exit(0); + } + if (scanResult === 0) { + cleanup(outputPath); + process.exit(0); + } + + // Scan findings are reported via stdout JSON below. Missing scan output + // (e.g. unwritable tmpdir) is a soft skip — not a hook runtime failure. + const scanOutput = readFileOrEmpty(outputPath).trim(); + cleanup(outputPath); + + if (!scanOutput) { + process.exit(0); + } + + const message = `React Doctor found issues in the changed files. Review this output and fix the regressions before finishing. For confirmed issues that cannot be fixed now, create GitHub issues with the rule, file/line, confidence, impact, and proposed fix.\n\n${scanOutput}`; + + if (hookEventName(input) === 'PostToolBatch') { + console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: message } })); + } else { + console.log(JSON.stringify({ additional_context: message })); + } +}; + +main().catch(fail); diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6601319 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.sol] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc8bbb8 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Repo root — copy to .env (gitignored) + +# ngrok authtoken for `npm run dev:general-wallet` +# https://dashboard.ngrok.com/get-started/your-authtoken +NGROK_AUTHTOKEN= + +# Optional: reserved ngrok domain (stable URL instead of random subdomain) +# Find yours at https://dashboard.ngrok.com/domains — e.g. immune-sheep-light.ngrok-free.app +NGROK_DOMAIN= + +# Optional: full Branding Layer iframe URL for host / credential-issuer / credential-verifier. +# Overrides NGROK_DOMAIN and the localhost default — useful when testing another wallet repo. +# Example: WALLET_IFRAME_URL=https://immune-sheep-light.ngrok-free.app/wallet/ +# WALLET_IFRAME_URL= + +# Optional: HTTPS for Host Layer examples (host, credential-issuer, credential-verifier). +# Required when embedding HTTPS wallet iframes for passkeys (secure-context ancestor chain). +# If examples/host/certs/dev-*.pem exist, HTTPS is enabled for all three automatically. +# HOST_HTTPS=1 +# HOST_SSL_CERT=examples/host/certs/dev-cert.pem +# HOST_SSL_KEY=examples/host/certs/dev-key.pem +# Generate: mkcert -install && mkcert -cert-file examples/host/certs/dev-cert.pem -key-file examples/host/certs/dev-key.pem ows-host.com ows-issuer.com ows-verifier.com localhost 127.0.0.1 + +# Optional: public origins for credential demos (defaults shown) +# ISSUER_ORIGIN=https://ows-issuer.com:5175 +# VERIFIER_ORIGIN=https://ows-verifier.com:5176 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3745953 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4277f7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Hardhat / Foundry +cache/ +artifacts/ +typechain-types/ +ignition/deployments/ + +# Environment +.env +.env.* +!.env.example + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# ngrok +.ngrok/ + +# Coverage +coverage/ + +# Local npm pack output for consumer installs before registry publish +.release-tarballs/ + +# Local HTTPS certs (mkcert) for host demos +examples/host/certs/*.pem +examples/credential-issuer/certs/*.pem +examples/credential-verifier/certs/*.pem diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..53524bd --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Shai-Hulud / keyv+cacheable compromise (2026-08-04): +# Known-bad seed versions are pinned away via package.json#overrides. +# Do not install keyv@6.0.0, flat-cache@6.1.24, file-entry-cache@11.1.6, +# cacheable@2.5.1, cacheable-request@13.0.20, cache-manager@7.2.10, ecto@5.0.1, +# or @cacheable/{memory@2.2.1,utils@2.5.1,node-cache@3.1.2,net@2.1.1}. diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..60102f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# Agent instructions — Open Wallet Standard (OWS) + +Monorepo: **1Shot API Open Wallet Standard** (`@1shotapi/open-wallet`). + +## Stack + +OWS uses three iframe layers: **Host Layer**, **Branding Layer**, and **Signing Layer**. + +``` +examples/host Host Layer — @1shotapi/ows-provider (EIP-1193) +examples/general-wallet Branding Layer — React + Tailwind general-purpose wallet demo +packages/ows-signer Signing Layer (plain JS, zero deps, on-chain) +packages/ows-signer-utils Branding Layer ↔ Signing Layer (evm.signMessage, etc.) +packages/ows-types Shared types, errors, and cross-layer utils (including SD-JWT VC) +packages/ows-oid4 Optional credentials add-on (CredentialsHelper, HTTP OID4 clients) +packages/ows-wallet-utils Branding Layer Postmate/RPC wrappers; credentials wire hooks only +packages/ows-provider Host Layer EIP-1193 + credentials proxy; SD-JWT verify +examples/shared Demo-only OID4 mocks, stores, and fixtures (not published) +packages/ows-onchain EIP-8244 deploy pipeline for ows-signer +spec/credentials/ OWS Credentials Extension normative docs +``` + +## Hard rules + +1. **`packages/ows-signer` is plain JavaScript only.** No TypeScript, bundler, dependencies, or build step. +2. **Signing Layer signs curves/digests, not chain APIs.** EIP-191, EIP-712, etc. belong in `ows-signer-utils`. +3. **Host Layer must never embed the Signing Layer directly.** Always Host → Branding → Signing. +4. **Signing Layer accepts `postMessage` only when `event.source === window.parent` and `window.parent !== window.top`.** +5. **Do not vendor Postmate.** Use the `postmate` package from npm. Set iframe `allow` (WebAuthn, clipboard) before navigation — see `OWSProxy` / `createSignerIframe`. +6. **Examples are not published.** + +## Code style + +Prefer **methods on objects** over standalone exported functions when the logic belongs to a single class or module (e.g. serialize an RPC envelope inside `RpcHostClient`, not as a public `createRpcRequest` helper). Export free functions only when they are genuinely shared utilities with multiple independent call sites. + +**No deprecations during active development.** OWS is pre-1.0 and not yet published for external consumers. When a pattern is renamed or superseded, update all call sites to the new API — do not leave `@deprecated` aliases, re-exports, or compatibility shims. Remove old names once migrations are complete. + +Prefer Typescript interfaces over generic types. Prefix all interfaces with I, ie IHolderSigner. HolderSigner would be an implementation. Prefer enums over discriminated strings. Prefix enums with E, ie EEip1193Method. + +Prefer **constructor parameter properties** for DTO / event / value-object classes instead of declaring fields and assigning them separately — it is shorter and easier to read: + +```ts +// Prefer +class AccountCreatedEvent extends OWSAnalyticsEvent { + constructor( + hostDomain: DomainString, + public readonly accountAddress: EVMAccountAddress, + ) { + super("AccountCreated", hostDomain); + } +} + +// Avoid +class AccountCreatedEvent extends OWSAnalyticsEvent { + readonly accountAddress: EVMAccountAddress; + constructor(hostDomain: DomainString, accountAddress: EVMAccountAddress) { + super("AccountCreated", hostDomain); + this.accountAddress = accountAddress; + } +} +``` + +## Branded types + +Use **branded primitives** from `@1shotapi/ows-types` (`ts-brand`) anywhere a value is semantically more than a raw `string`, `number`, or `bigint`. + +**Always prefer a branded primitive over an unbranded `string`, `number`, or `bigint`.** Do not use raw primitives for domain values (addresses, call IDs, chain IDs, amounts, etc.) when a branded type exists or should exist in `ows-types`. + +**Never union a branded type with its base primitive** (e.g. `EVMAccountAddress | string`, `DomainString | string`, `UnixTimestamp | number`). That defeats the brand — callers can pass unvalidated strings/numbers and TypeScript will not catch it. Accept only the branded type at API boundaries; brand at the validation/derivation site before calling. + +- Define each primitive in `packages/ows-types/src/primitives/.ts` using the same pattern as `RPCCallId` (type alias + `make()` constructor). +- Export from `packages/ows-types/src/primitives/index.ts`. +- Examples: `EVMAccountAddress` (not `0x${string}` or viem `Address`), `EVMSignatureHex`, `SolanaAccountAddress`, `SolanaProgramAddress`, `SolanaMintAddress`, `BitcoinSegwitAccountAddress`, `BitcoinChainId` (`"Bitcoin"` / `"BitcoinTestnet"`), `SolanaChainId` (`"solana:mainnet"` / `"solana:devnet"` / `"solana:testnet"`), `RPCCallId`. +- Narrow `OWSChainId` with `ChainUtils.isEVMChainId` / `isBitcoinChainId` / `isSolanaChainId` (see `packages/ows-types/src/utils/ChainUtils.ts`). +- Brand at the point of validation or derivation (e.g. Zod `.transform(EVMAccountAddress)`, or after `publicKeyToAddress`). +- For polymorphic APIs (e.g. EIP-1193 `request`), use a **mapped method table** (`EIP1193Requests` in `ows-types`) with a conditional generic on `request()` so callers get inferred branded results without `as` casts. + +Do not introduce parallel branded types in consumer packages — add or extend primitives in `ows-types` instead. + +## Tooling + +- Node.js 22+, npm workspaces +- TypeScript packages: `tsc` → `dist/` +- Changesets for `@1shotapi/*` independent versioning +- On-chain: https://github.com/TtheBC01/eip-8244 + +## Common commands + +```bash +npm install && npm run build && npm test +npm run dev -w @1shotapi/ows-example-general-wallet +npm run dev -w @1shotapi/ows-example-host +npm run dev -w @1shotapi/ows-example-credential-issuer +npm run dev -w @1shotapi/ows-example-credential-verifier +``` + +## Status + +Bootstrapped; OWS signer logic and SDKs not yet implemented. + +## Agent Skills + +First-party integrator skills live in [1Shot-API/skills](https://github.com/1Shot-API/skills) (not in this repo). Install globally for local Cursor: + +```bash +npm run skills:install +# ows-branding-layer + 1shot-wallet → ~/.cursor/skills +``` + +When you change OWS packages, Branding/Signing protocol, or Host integration surfaces in a way that affects integrators, update the matching skill in `1Shot-API/skills` (`ows-branding-layer`, and `1shot-wallet` when Host-facing guidance must change) in the same effort. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..181b11f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to OWS + +Thank you for contributing to the **Open Wallet Standard (OWS)** reference implementation. + +## Getting started + +1. Fork and clone [open-wallet](https://github.com/1Shot-API/open-wallet). +2. Use Node.js 22+ (`nvm use`). +3. `npm install` from the repo root. +4. `npm run build` and `npm test`. + +## Package boundaries + +| Path | Language | Build | Published | +|------|----------|-------|-----------| +| `packages/ows-signer` | Plain JavaScript | **None** | `@1shotapi/ows-signer` | +| `packages/ows-types` | TypeScript | `tsc` | `@1shotapi/ows-types` | +| `packages/ows-signer-utils` | TypeScript | `tsc` | `@1shotapi/ows-signer-utils` | +| `packages/ows-wallet-utils` | TypeScript | `tsc` | `@1shotapi/ows-wallet-utils` | +| `packages/ows-oid4` | TypeScript | `tsc` | `@1shotapi/ows-oid4` (optional credentials) | +| `packages/ows-provider` | TypeScript | `tsc` | `@1shotapi/ows-provider` | +| `packages/ows-onchain` | TypeScript + Solidity | Hardhat | `@1shotapi/ows-onchain` | +| `examples/*` | TypeScript | Vite (dev) | No | + +**Critical rule:** `@1shotapi/ows-signer` must never acquire a build step or npm dependencies. Source is published verbatim and deployed on-chain as-is. + +**Postmate:** Use the external [`postmate`](https://github.com/dollarshaveclub/postmate) package — do not vendor it in this repo. + +### Workspace commands + +```bash +npm run dev -w @1shotapi/ows-example-host +npm run build -w @1shotapi/ows-signer-utils +``` + +### Versioning + +Include a changeset for user-facing changes to publishable packages: `npm run changeset`. + +## Porting from 1ShotPay (maintainers) + +[1ShotPay](https://1shotpay.com) validates OWS patterns in a **private repository**: + +1. Signing Layer logic → `packages/ows-signer` (plain JS, curve-based signing only). +2. Chain marshalling → `packages/ows-signer-utils`. +3. General-purpose Branding Layer patterns → `examples/general-wallet` via `ows-wallet-utils`. +4. Host Layer EIP-1193 → `packages/ows-provider`. + +Do not commit proprietary assets, API keys, or 1ShotPay-specific endpoints. + +## Pull requests + +- Keep PRs focused on one OWS package or concern. +- Ensure `npm run build` and `npm test` pass. +- Do not add dependencies to `ows-signer`. + +## Security + +Report security issues to info@1shotapi.com rather than opening a public issue. diff --git a/LICENSE b/LICENSE index d0d5520..37ec93a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,191 @@ -MIT License - -Copyright (c) 2026 1Shot API - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..967c737 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +1Shot +Copyright 2026 1Shot API, Inc. + +This product includes software developed by 1Shot API, Inc. diff --git a/README.md b/README.md new file mode 100644 index 0000000..57e7a92 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Open Wallet Standard (OWS) + +**1Shot API Open Wallet Standard** — open-source reference implementation for **WebAuthn PRF** non-custodial wallets. See the [1Shot API blog post](https://1shotapi.com/blog/rip-embedded-wallets-stop-paying-privy) for motivation. + +> Use of 1Shot-hosted public infrastructure is subject to the [Public Infrastructure Terms](https://1shotapi.com/legal/public-infrastructure-terms) and [Acceptable Use Policy](https://1shotapi.com/legal/acceptable-use-policy). By accessing or using those hosted services, you agree to those terms. + +OWS defines a three-layer iframe architecture: **Host Layer** → **Branding Layer** → **Signing Layer**. The Signing Layer is published on-chain ([EIP-8244](https://github.com/TtheBC01/eip-8244)) as immutable plain JavaScript with zero dependencies. + +## Architecture + +``` +Host Layer @1shotapi/ows-provider EIP-1193 for viem/ethers + └── Branding Layer @1shotapi/ows-wallet-utils Your branding + signing UX + └── Signing Layer @1shotapi/ows-signer KMS/HSM-style curve signing + ▲ + └── @1shotapi/ows-signer-utils marshals EVM/Bitcoin/etc. → digest → sign +``` + +| Layer | Package | Published | +|-------|---------|-----------| +| Shared types | `@1shotapi/ows-types` | Yes | +| Signing Layer | `@1shotapi/ows-signer` | Yes (plain JS, on-chain) | +| Branding ↔ Signing SDK | `@1shotapi/ows-signer-utils` | Yes | +| Host ↔ Branding SDK | `@1shotapi/ows-wallet-utils` | Yes | +| Host Layer provider | `@1shotapi/ows-provider` | Yes | +| On-chain deploy | `@1shotapi/ows-onchain` | Yes | +| Demos | `examples/*` | No | + +Cross-frame messaging for Host ↔ Branding uses [postmate](https://github.com/dollarshaveclub/postmate) (external package, not vendored here). + +## Quick start + +**Requirements:** Node.js 22+ + +```bash +git clone https://github.com/1Shot-API/open-wallet.git +cd open-wallet +npm install +npm run build +npm test +``` + +### Run the OWS demos + +Copy [`.env.example`](.env.example) to `.env` and set `NGROK_AUTHTOKEN` and optionally `NGROK_DOMAIN` (your reserved ngrok domain) for HTTPS passkey testing. + +```bash +# Terminal 1 — general-purpose Branding Layer + Signing Layer (single ngrok tunnel) +npm run dev:general-wallet + +# Terminal 2 — Host Layer app (reads NGROK_DOMAIN from repo root .env) +npm run dev -w @1shotapi/ows-example-host +``` + +See [examples/general-wallet/README.md](examples/general-wallet/README.md) and [examples/host/README.md](examples/host/README.md) for details. + +## Repository layout + +``` +packages/ + ows-types/ Shared types and errors (all layers) + ows-signer/ Plain JS Signing Layer — no build step + ows-signer-utils/ Branding Layer ↔ Signing Layer (evm.signMessage, etc.) + ows-wallet-utils/ Host Layer ↔ Branding Layer (Postmate RPC wrappers) + ows-provider/ EIP-1193 provider for Host Layer apps + ows-onchain/ EIP-8244 compress → deploy → serve +examples/ + general-wallet/ General-purpose Branding Layer demo (MetaMask-style) + host/ Host Layer reference application + shared/ Demo mocks for credentials (not published) +``` + +## Signing Layer design + +`@1shotapi/ows-signer` is intentionally minimal — a **curve-based signing primitive** (starting with secp256k1), not a chain-aware wallet. Chain-specific marshalling (EIP-191, EIP-712, etc.) lives in `@1shotapi/ows-signer-utils` so the on-chain artifact stays small and stable. + +## Monorepo tooling + +npm workspaces + [Changesets](https://github.com/changesets/changesets) for independent `@1shotapi/*` package versioning. + +```bash +npm run changeset # describe a change +npm run version-packages # bump versions + changelogs +npm run release # build + publish to npm +``` + +## Status + +**Scaffold only** — package structure and OWS branding are in place. Signer logic, SDKs, and on-chain pipeline are forthcoming. + +## Agent Skills + +Build a Branding Layer in another repository with the published skill from [1Shot-API/skills](https://github.com/1Shot-API/skills): + +```bash +npx skills add 1Shot-API/skills/ows-branding-layer -g -a cursor -y +# or from this monorepo: npm run skills:install +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/examples/credential-issuer/README.md b/examples/credential-issuer/README.md new file mode 100644 index 0000000..bc4004d --- /dev/null +++ b/examples/credential-issuer/README.md @@ -0,0 +1,53 @@ +# OWS Example — Credential Issuer (OID4VCI HTTP) + +Host Layer demo that offers a real HTTPS OID4VCI credential offer to the wallet via `proxy.credentials.acceptOffer()`. + +Vite middleware serves: + +- `GET /.well-known/openid-credential-issuer` +- `GET /jwks` +- `GET /offers/demo` +- `POST /token` (pre-authorized_code → access token + `c_nonce`) +- `POST /credential` (verify PoP JWT; issue SD-JWT VC) + +Public issuer identity is **`ows-issuer.com`** (offer URI, well-known `credential_issuer`, and SD-JWT `iss`), even if you open the UI via `localhost`. Bind locally; point hosts + wallet fetches at `ows-issuer.com`. + +**Chrome third-party storage partitioning:** the Branding Layer iframe’s `localStorage` is keyed by top-level site. Issuing on `ows-issuer.com` and presenting on `ows-verifier.com` yields **separate credential stores**. For end-to-end demos, open both hosts under the same site: + +- Issuer UI: `https://ows-host.com:5175` +- Verifier UI: `https://ows-host.com:5176` + +(`iss` / offer URLs can still say `ows-issuer.com`; only the browser address bar needs the shared host.) + +## Run + +```bash +# Terminal 1 — branding wallet (HttpOid4vciClient) +npm run dev:general-wallet + +# Terminal 2 — issuer host +npm run dev -w @1shotapi/ows-example-credential-issuer +``` + +Open **https://ows-host.com:5175** (or https://ows-issuer.com:5175) and click **Send offer to wallet**. + +Optional: override with `ISSUER_ORIGIN` (default `http(s)://ows-issuer.com:5175`). Branding iframe URL follows the same rules as [`examples/host`](../host/README.md) (`WALLET_IFRAME_URL` → `NGROK_DOMAIN` → localhost). + +## HTTPS (passkeys) + +An HTTPS wallet iframe requires an HTTPS host page (secure-context ancestor chain). Same setup as [`examples/host`](../host/README.md): + +```bash +mkcert -install +mkcert -cert-file examples/host/certs/dev-cert.pem \ + -key-file examples/host/certs/dev-key.pem \ + ows-host.com ows-issuer.com ows-verifier.com localhost 127.0.0.1 +``` + +Hosts file: + +```text +127.0.0.1 ows-host.com ows-issuer.com ows-verifier.com +``` + +Issuer/verifier reuse `examples/host/certs/` when present. Then open `https://ows-issuer.com:5175`. diff --git a/examples/credential-issuer/certs/.gitkeep b/examples/credential-issuer/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/credential-issuer/index.html b/examples/credential-issuer/index.html new file mode 100644 index 0000000..e6fb9d6 --- /dev/null +++ b/examples/credential-issuer/index.html @@ -0,0 +1,29 @@ + + + + + + OWS — Mock Credential Issuer + + + + +
+
+

+
+ + +
+

+ +
+
+ +
+ + + diff --git a/examples/credential-issuer/package.json b/examples/credential-issuer/package.json new file mode 100644 index 0000000..bc3cbdb --- /dev/null +++ b/examples/credential-issuer/package.json @@ -0,0 +1,23 @@ +{ + "name": "@1shotapi/ows-example-credential-issuer", + "version": "0.0.0", + "private": true, + "description": "OWS credential issuer host demo — HTTP OID4VCI well-known/token/credential + acceptOffer", + "type": "module", + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "clean": "node scripts/clean.mjs", + "lint": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*" + }, + "devDependencies": { + "dotenv": "^16.4.7", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } +} diff --git a/examples/credential-issuer/scripts/clean.mjs b/examples/credential-issuer/scripts/clean.mjs new file mode 100644 index 0000000..f5e4cb9 --- /dev/null +++ b/examples/credential-issuer/scripts/clean.mjs @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +fs.rmSync(path.join(root, "dist"), { recursive: true, force: true }); +console.log("Removed dist/"); diff --git a/examples/credential-issuer/scripts/dev.mjs b/examples/credential-issuer/scripts/dev.mjs new file mode 100644 index 0000000..0c62d40 --- /dev/null +++ b/examples/credential-issuer/scripts/dev.mjs @@ -0,0 +1,85 @@ +/** + * Start Vite for the mock credential issuer host (port 5175). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import net from "node:net"; +import { createServer } from "vite"; +import dotenv from "dotenv"; +import { resolveHttpsOptions, walletIframeUrl } from "../../host-dev.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const port = Number(process.env.PORT ?? 5175); + +let viteServer; +let shuttingDown = false; + +function waitForPort(host, listenPort, timeoutMs = 30_000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const tryConnect = () => { + const socket = net.connect({ host, port: listenPort }, () => { + socket.end(); + resolve(undefined); + }); + socket.on("error", () => { + if (Date.now() - started > timeoutMs) { + reject(new Error(`Timed out waiting for port ${listenPort}`)); + return; + } + setTimeout(tryConnect, 200); + }); + }; + tryConnect(); + }); +} + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + try { + if (viteServer) await viteServer.close(); + } catch (error) { + console.error(`Failed to shut down (${signal}):`, error); + process.exit(1); + return; + } + process.exit(0); +} + +process.on("SIGINT", () => void shutdown("SIGINT")); +process.on("SIGTERM", () => void shutdown("SIGTERM")); + +try { + viteServer = await createServer({ + configFile: path.join(__dirname, "../vite.config.mjs"), + server: { port, host: "0.0.0.0" }, + }); + await viteServer.listen(); + await waitForPort("127.0.0.1", port); + + const https = resolveHttpsOptions({ + certsDir: path.join(__dirname, "../certs"), + exampleLabel: "examples/credential-issuer", + }); + const scheme = https ? "https" : "http"; + console.log(`OWS credential issuer demo: ${scheme}://localhost:${port}`); + console.log( + ` Public issuer origin (offers / iss): ${scheme}://ows-issuer.com:${port}`, + ); + console.log( + ` Also try: ${scheme}://ows-issuer.com:${port} (hosts file → 127.0.0.1)`, + ); + console.log(` Branding Layer iframe: ${walletIframeUrl()}`); + if (scheme === "http") { + console.log( + " Tip: HTTPS host is required for passkeys in nested HTTPS iframes — see examples/host/README.md", + ); + } +} catch (error) { + console.error("Failed to start credential issuer dev server:", error); + process.exit(1); +} diff --git a/examples/credential-issuer/src/env.d.ts b/examples/credential-issuer/src/env.d.ts new file mode 100644 index 0000000..173e871 --- /dev/null +++ b/examples/credential-issuer/src/env.d.ts @@ -0,0 +1,4 @@ +/// + +declare const __WALLET_IFRAME_URL__: string; +declare const __ISSUER_ORIGIN__: string; diff --git a/examples/credential-issuer/src/main.ts b/examples/credential-issuer/src/main.ts new file mode 100644 index 0000000..6d4afc1 --- /dev/null +++ b/examples/credential-issuer/src/main.ts @@ -0,0 +1,66 @@ +import { OWSProxy } from "@1shotapi/ows-provider"; +import { CredentialOfferUri } from "@1shotapi/ows-types"; +import "./styles.css"; + +declare const __WALLET_IFRAME_URL__: string; +declare const __ISSUER_ORIGIN__: string; + +const offerUriEl = document.getElementById("offer-uri")!; +const issueButton = document.getElementById("issue-button") as HTMLButtonElement; +const showWalletButton = document.getElementById( + "show-wallet-button", +) as HTMLButtonElement; +const statusEl = document.getElementById("status") as HTMLParagraphElement; +const resultOutput = document.getElementById("result-output") as HTMLPreElement; +const walletContainer = document.getElementById("wallet-container")!; + +const offerUri = CredentialOfferUri( + `${__ISSUER_ORIGIN__.replace(/\/$/, "")}/offers/demo`, +); +offerUriEl.textContent = `Offer URI: ${offerUri}`; + +function setStatus(message: string, isError = false): void { + statusEl.textContent = message; + statusEl.classList.toggle("status--error", isError); +} + +async function main(): Promise { + const proxy = await OWSProxy.create(walletContainer, __WALLET_IFRAME_URL__, { + // Branding iframe (ngrok) fetches loopback issuer OID4 endpoints. + allowLocalAccess: true, + }); + + showWalletButton.addEventListener("click", () => { + proxy.showWallet(); + }); + + issueButton.addEventListener("click", () => { + void (async () => { + issueButton.disabled = true; + resultOutput.hidden = true; + setStatus("Sending credential offer to wallet…"); + + try { + const receipt = await proxy.credentials.acceptOffer({ + credentialOfferUri: offerUri, + }); + resultOutput.textContent = JSON.stringify(receipt, null, 2); + resultOutput.hidden = false; + setStatus("Credential stored in wallet."); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Credential offer failed"; + setStatus(message, true); + } finally { + issueButton.disabled = false; + } + })(); + }); + + setStatus("Ready — OID4VCI HTTP issuer + general-wallet credentials."); +} + +main().catch((error: unknown) => { + console.error("[ows-example-credential-issuer] failed", error); + setStatus("Failed to start", true); +}); diff --git a/examples/credential-issuer/src/oid4vci-plugin.ts b/examples/credential-issuer/src/oid4vci-plugin.ts new file mode 100644 index 0000000..537f6ba --- /dev/null +++ b/examples/credential-issuer/src/oid4vci-plugin.ts @@ -0,0 +1,248 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin } from "vite"; +import { + CredentialClaimName, + CredentialIssuer, + ISO8601DateTime, + ProofUtils, +} from "@1shotapi/ows-types"; +import { applyDemoCors } from "../../shared/src/demo/cors.js"; +import { DEMO_ISSUER_PUBLIC_JWK } from "../../shared/src/demo/demo-keys.js"; +import { issueDemoSdJwtVc } from "../../shared/src/demo/issuer.js"; + +const PRE_AUTH_CODE = "ows-demo-preauth-code"; +/** Access-token lifetime advertised as `expires_in` on `/token`. */ +const ACCESS_TOKEN_TTL_MS = 3600_000; +type AccessTokenSession = { cNonce: string; expiresAtMs: number }; +const ACCESS_TOKENS = new Map(); +/** Demo endpoints only — reject oversized POSTs before buffering. */ +const MAX_BODY_BYTES = 1_048_576; + +function purgeExpiredAccessTokens(nowMs = Date.now()): void { + for (const [token, session] of ACCESS_TOKENS) { + if (session.expiresAtMs <= nowMs) { + ACCESS_TOKENS.delete(token); + } + } +} + +function getAccessTokenSession(token: string): AccessTokenSession | undefined { + const nowMs = Date.now(); + purgeExpiredAccessTokens(nowMs); + const session = ACCESS_TOKENS.get(token); + if (!session) return undefined; + if (session.expiresAtMs <= nowMs) { + ACCESS_TOKENS.delete(token); + return undefined; + } + return session; +} + +function readBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (c: Buffer) => { + size += c.length; + if (size > MAX_BODY_BYTES) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + const ct = String(req.headers["content-type"] ?? ""); + if (ct.includes("application/json")) { + try { + resolve(raw ? (JSON.parse(raw) as Record) : {}); + } catch (error) { + reject(error); + } + return; + } + resolve(Object.fromEntries(new URLSearchParams(raw))); + }); + req.on("error", reject); + }); +} + +function sendJson( + res: ServerResponse, + status: number, + body: unknown, +): void { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + applyDemoCors(res); + res.end(JSON.stringify(body)); +} + +/** + * Vite plugin: OID4VCI well-known / token / credential / offer endpoints. + * + * @param options.publicOrigin Stable public issuer origin (`https://ows-issuer.com:5175`). + * Used for `credential_issuer`, endpoint URLs, proof audience, and SD-JWT `iss` + * even when the process was reached via localhost. + */ +export function oid4vciDemoPlugin(options: { publicOrigin: string }): Plugin { + const base = options.publicOrigin.replace(/\/$/, ""); + const credentialIssuer = CredentialIssuer(base); + + return { + name: "ows-oid4vci-demo", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + // OPTIONS + open CORS are handled by demoCorsPlugin (vite.config). + const url = new URL(req.url ?? "/", "http://localhost"); + const path = url.pathname; + + try { + if ( + req.method === "GET" && + path === "/.well-known/openid-credential-issuer" + ) { + sendJson(res, 200, { + credential_issuer: String(credentialIssuer), + credential_endpoint: `${base}/credential`, + token_endpoint: `${base}/token`, + jwks_uri: `${base}/jwks`, + credential_configurations_supported: { + KycCredential: { + format: "vc+sd-jwt", + scope: "kyc", + proof_types_supported: { + jwt: { proof_signing_alg_values_supported: ["EdDSA"] }, + }, + }, + }, + }); + return; + } + + if (req.method === "GET" && path === "/jwks") { + sendJson(res, 200, { + keys: [{ ...DEMO_ISSUER_PUBLIC_JWK, alg: "EdDSA", use: "sig" }], + }); + return; + } + + if (req.method === "GET" && path === "/offers/demo") { + sendJson(res, 200, { + credential_issuer: String(credentialIssuer), + credential_configuration_ids: ["KycCredential"], + grants: { + "urn:ietf:params:oauth:grant-type:pre-authorized_code": { + "pre-authorized_code": PRE_AUTH_CODE, + }, + }, + }); + return; + } + + if (req.method === "POST" && path === "/token") { + const body = await readBody(req); + if ( + body.grant_type !== + "urn:ietf:params:oauth:grant-type:pre-authorized_code" || + body["pre-authorized_code"] !== PRE_AUTH_CODE + ) { + sendJson(res, 400, { error: "invalid_grant" }); + return; + } + const accessToken = `atk_${crypto.randomUUID()}`; + const cNonce = `cnonce_${crypto.randomUUID()}`; + purgeExpiredAccessTokens(); + ACCESS_TOKENS.set(accessToken, { + cNonce, + expiresAtMs: Date.now() + ACCESS_TOKEN_TTL_MS, + }); + sendJson(res, 200, { + access_token: accessToken, + token_type: "bearer", + expires_in: ACCESS_TOKEN_TTL_MS / 1000, + c_nonce: cNonce, + c_nonce_expires_in: 300, + }); + return; + } + + if (req.method === "POST" && path === "/credential") { + const auth = String(req.headers.authorization ?? ""); + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + const session = getAccessTokenSession(token); + if (!session) { + sendJson(res, 401, { error: "invalid_token" }); + return; + } + const body = await readBody(req); + const proof = body.proof as { jwt?: string } | undefined; + const proofJwt = proof?.jwt; + if (!proofJwt) { + sendJson(res, 400, { error: "invalid_proof" }); + return; + } + + const proofResult = await ProofUtils.verifyOid4vciProofJwt({ + jwt: proofJwt, + expectedAudience: credentialIssuer, + expectedNonce: session.cNonce, + }); + if (!proofResult.valid || !proofResult.holderPublicKeyJwk) { + const reasons = Array.isArray(proofResult.reasons) + ? proofResult.reasons + : []; + sendJson(res, 400, { + error: "invalid_proof", + error_description: + reasons.length > 0 ? reasons.join("; ") : "invalid proof", + }); + return; + } + + const issuedAt = ISO8601DateTime(new Date().toISOString()); + const sdJwt = await issueDemoSdJwtVc({ + issuer: String(credentialIssuer), + vct: "KycCredential", + claims: { + ageOver18: true, + country: "US", + assuranceLevel: "substantial", + verifiedAt: issuedAt, + }, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + CredentialClaimName("assuranceLevel"), + CredentialClaimName("verifiedAt"), + ], + holderPublicKeyJwk: proofResult.holderPublicKeyJwk, + }); + + sendJson(res, 200, { + credential: sdJwt, + c_nonce: session.cNonce, + }); + // One-shot access token — prevent replay for additional credentials. + ACCESS_TOKENS.delete(token); + return; + } + } catch (error: unknown) { + sendJson(res, 500, { + error: "server_error", + error_description: + error instanceof Error ? error.message : String(error), + }); + return; + } + + next(); + }); + }, + }; +} + +export function publicOfferUri(origin: string): string { + return `${origin.replace(/\/$/, "")}/offers/demo`; +} diff --git a/examples/credential-issuer/src/styles.css b/examples/credential-issuer/src/styles.css new file mode 100644 index 0000000..433c3b1 --- /dev/null +++ b/examples/credential-issuer/src/styles.css @@ -0,0 +1,50 @@ +.site-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid #8884; +} + +.subtitle { + margin: 0.25rem 0 0; + opacity: 0.8; + font-size: 0.9rem; +} + +.content { + padding: 1.25rem; + max-width: 40rem; +} + +.panel { + display: grid; + gap: 0.75rem; +} + +.mono { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + word-break: break-all; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +button { + padding: 0.5rem 0.875rem; + font: inherit; + cursor: pointer; +} + +.status--error { + color: #c00; +} + +.output { + padding: 0.75rem; + background: #8881; + border-radius: 6px; + overflow: auto; + font-size: 0.8rem; +} diff --git a/examples/credential-issuer/tsconfig.json b/examples/credential-issuer/tsconfig.json new file mode 100644 index 0000000..08ec0d4 --- /dev/null +++ b/examples/credential-issuer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@ows-shared": ["../shared/src/index.ts"] + } + }, + "include": ["src/**/*", "../shared/src/**/*"] +} diff --git a/examples/credential-issuer/vite.config.mjs b/examples/credential-issuer/vite.config.mjs new file mode 100644 index 0000000..dcf7f75 --- /dev/null +++ b/examples/credential-issuer/vite.config.mjs @@ -0,0 +1,66 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import { defineConfig } from "vite"; +import { + demoPublicOrigin, + OWS_ISSUER_HOSTNAME, + OWS_ISSUER_PORT, + resolveHttpsOptions, + walletIframeUrl, +} from "../host-dev.mjs"; +import { demoCorsPlugin } from "../shared/src/demo/cors.ts"; +import { oid4vciDemoPlugin } from "./src/oid4vci-plugin.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const https = resolveHttpsOptions({ + certsDir: path.resolve(__dirname, "certs"), + exampleLabel: "examples/credential-issuer", +}); + +const port = Number(process.env.PORT ?? OWS_ISSUER_PORT); +const scheme = https ? "https" : "http"; +const issuerOrigin = demoPublicOrigin({ + hostname: OWS_ISSUER_HOSTNAME, + port, + scheme, + envValue: process.env.ISSUER_ORIGIN, +}); + +export default defineConfig({ + define: { + __WALLET_IFRAME_URL__: JSON.stringify(walletIframeUrl()), + __ISSUER_ORIGIN__: JSON.stringify(issuerOrigin), + }, + resolve: { + alias: { + "@ows-shared": path.resolve(__dirname, "../shared/src/index.ts"), + }, + }, + plugins: [ + demoCorsPlugin(), + oid4vciDemoPlugin({ publicOrigin: issuerOrigin }), + ], + server: { + port, + host: "0.0.0.0", + allowedHosts: true, + // Disable Vite default CORS (localhost-only). demoCorsPlugin allows *. + cors: false, + ...(https ? { https } : {}), + }, + preview: { + port, + host: "0.0.0.0", + cors: false, + ...(https ? { https } : {}), + }, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + }, +}); diff --git a/examples/credential-verifier/README.md b/examples/credential-verifier/README.md new file mode 100644 index 0000000..1f6b374 --- /dev/null +++ b/examples/credential-verifier/README.md @@ -0,0 +1,50 @@ +# OWS Example — Credential Verifier (OID4VP HTTP) + +Host Layer demo that requests an OID4VP presentation from the wallet via `proxy.credentials.present()` and validates it (JWKS + KYC policy + custody UI). + +Vite middleware serves: + +- `GET /request/demo` — DCQL query, nonce, `response_uri`, `direct_post.jwt` + encryption JWKS +- `POST /response` — plaintext or JWE VP token +- `GET /request/latest` / `GET /response/latest` — inspection helpers +- `GET /issuer-jwks` — proxy/fallback issuer signing keys + +Public verifier identity is **`ows-verifier.com`**. Issuer allow-list / JWKS use **`ows-issuer.com`**. + +**Chrome third-party storage partitioning:** open the verifier as **`https://ows-host.com:5176`** when you issued on **`https://ows-host.com:5175`**, so the wallet iframe shares the same partitioned `localStorage` as issuance. Using `ows-verifier.com` after issuing on `ows-issuer.com` looks like an empty wallet. + +## Run + +```bash +# Terminal 1 — branding wallet (HttpOid4vpClient) +npm run dev:general-wallet + +# Terminal 2 — issue a demo credential first +npm run dev -w @1shotapi/ows-example-credential-issuer + +# Terminal 3 — verifier host +npm run dev -w @1shotapi/ows-example-credential-verifier +``` + +Open **https://ows-host.com:5176** (or https://ows-verifier.com:5176) and click **Request presentation**. + +Optional env: `ISSUER_ORIGIN` / `VERIFIER_ORIGIN` (defaults `ows-issuer.com:5175` / `ows-verifier.com:5176`). Branding iframe URL follows the same rules as [`examples/host`](../host/README.md) (`WALLET_IFRAME_URL` → `NGROK_DOMAIN` → localhost). + +## HTTPS (passkeys) + +An HTTPS wallet iframe requires an HTTPS host page (secure-context ancestor chain). Same setup as [`examples/host`](../host/README.md): + +```bash +mkcert -install +mkcert -cert-file examples/host/certs/dev-cert.pem \ + -key-file examples/host/certs/dev-key.pem \ + ows-host.com ows-issuer.com ows-verifier.com localhost 127.0.0.1 +``` + +Hosts file: + +```text +127.0.0.1 ows-host.com ows-issuer.com ows-verifier.com +``` + +Then open `https://ows-verifier.com:5176`. diff --git a/examples/credential-verifier/certs/.gitkeep b/examples/credential-verifier/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/credential-verifier/index.html b/examples/credential-verifier/index.html new file mode 100644 index 0000000..48cf158 --- /dev/null +++ b/examples/credential-verifier/index.html @@ -0,0 +1,44 @@ + + + + + + OWS — Mock Credential Verifier + + + + +
+
+

+
+ + +
+

+
+ + +
+ +
+ + + diff --git a/examples/credential-verifier/package.json b/examples/credential-verifier/package.json new file mode 100644 index 0000000..51ef445 --- /dev/null +++ b/examples/credential-verifier/package.json @@ -0,0 +1,24 @@ +{ + "name": "@1shotapi/ows-example-credential-verifier", + "version": "0.0.0", + "private": true, + "description": "OWS credential verifier host demo — HTTP OID4VP request_uri/response + present", + "type": "module", + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "clean": "node scripts/clean.mjs", + "lint": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*", + "jose": "^6.0.11" + }, + "devDependencies": { + "dotenv": "^16.4.7", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } +} diff --git a/examples/credential-verifier/scripts/clean.mjs b/examples/credential-verifier/scripts/clean.mjs new file mode 100644 index 0000000..f5e4cb9 --- /dev/null +++ b/examples/credential-verifier/scripts/clean.mjs @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +fs.rmSync(path.join(root, "dist"), { recursive: true, force: true }); +console.log("Removed dist/"); diff --git a/examples/credential-verifier/scripts/dev.mjs b/examples/credential-verifier/scripts/dev.mjs new file mode 100644 index 0000000..361d88e --- /dev/null +++ b/examples/credential-verifier/scripts/dev.mjs @@ -0,0 +1,85 @@ +/** + * Start Vite for the mock credential verifier host (port 5176). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import net from "node:net"; +import { createServer } from "vite"; +import dotenv from "dotenv"; +import { resolveHttpsOptions, walletIframeUrl } from "../../host-dev.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const port = Number(process.env.PORT ?? 5176); + +let viteServer; +let shuttingDown = false; + +function waitForPort(host, listenPort, timeoutMs = 30_000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const tryConnect = () => { + const socket = net.connect({ host, port: listenPort }, () => { + socket.end(); + resolve(undefined); + }); + socket.on("error", () => { + if (Date.now() - started > timeoutMs) { + reject(new Error(`Timed out waiting for port ${listenPort}`)); + return; + } + setTimeout(tryConnect, 200); + }); + }; + tryConnect(); + }); +} + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + try { + if (viteServer) await viteServer.close(); + } catch (error) { + console.error(`Failed to shut down (${signal}):`, error); + process.exit(1); + return; + } + process.exit(0); +} + +process.on("SIGINT", () => void shutdown("SIGINT")); +process.on("SIGTERM", () => void shutdown("SIGTERM")); + +try { + viteServer = await createServer({ + configFile: path.join(__dirname, "../vite.config.mjs"), + server: { port, host: "0.0.0.0" }, + }); + await viteServer.listen(); + await waitForPort("127.0.0.1", port); + + const https = resolveHttpsOptions({ + certsDir: path.join(__dirname, "../certs"), + exampleLabel: "examples/credential-verifier", + }); + const scheme = https ? "https" : "http"; + console.log(`OWS credential verifier demo: ${scheme}://localhost:${port}`); + console.log( + ` Public verifier origin: ${scheme}://ows-verifier.com:${port}`, + ); + console.log( + ` Also try: ${scheme}://ows-verifier.com:${port} (hosts file → 127.0.0.1)`, + ); + console.log(` Branding Layer iframe: ${walletIframeUrl()}`); + if (scheme === "http") { + console.log( + " Tip: HTTPS host is required for passkeys in nested HTTPS iframes — see examples/host/README.md", + ); + } +} catch (error) { + console.error("Failed to start credential verifier dev server:", error); + process.exit(1); +} diff --git a/examples/credential-verifier/src/env.d.ts b/examples/credential-verifier/src/env.d.ts new file mode 100644 index 0000000..d68e222 --- /dev/null +++ b/examples/credential-verifier/src/env.d.ts @@ -0,0 +1,5 @@ +/// + +declare const __WALLET_IFRAME_URL__: string; +declare const __VERIFIER_ORIGIN__: string; +declare const __ISSUER_ORIGIN__: string; diff --git a/examples/credential-verifier/src/main.ts b/examples/credential-verifier/src/main.ts new file mode 100644 index 0000000..450aaaf --- /dev/null +++ b/examples/credential-verifier/src/main.ts @@ -0,0 +1,239 @@ +import { + MOCK_KYC_POLICY, + type CustodyStep, + type MockVerifierResult, +} from "../../shared/src/index.js"; +import { OWSProxy } from "@1shotapi/ows-provider"; +import { + CredentialIssuer, + PresentationRequestUri, + type PresentationResult, +} from "@1shotapi/ows-types"; +import { DEMO_ISSUER_PUBLIC_JWK } from "../../shared/src/demo/demo-keys.js"; +import { validateMockPresentation } from "../../shared/src/demo-flow.js"; +import "./styles.css"; + +declare const __WALLET_IFRAME_URL__: string; +declare const __VERIFIER_ORIGIN__: string; +declare const __ISSUER_ORIGIN__: string; + +const requestUriEl = document.getElementById("request-uri")!; +const verifyButton = document.getElementById("verify-button") as HTMLButtonElement; +const showWalletButton = document.getElementById( + "show-wallet-button", +) as HTMLButtonElement; +const statusEl = document.getElementById("status") as HTMLParagraphElement; +const inspectionEl = document.getElementById("inspection")!; +const custodyList = document.getElementById("custody-list")!; +const credentialMeta = document.getElementById("credential-meta")!; +const claimCards = document.getElementById("claim-cards")!; +const resultOutput = document.getElementById("result-output") as HTMLPreElement; +const walletContainer = document.getElementById("wallet-container")!; + +const verifierOrigin = __VERIFIER_ORIGIN__.replace(/\/$/, ""); +const issuerOrigin = __ISSUER_ORIGIN__.replace(/\/$/, ""); +const requestUri = PresentationRequestUri(`${verifierOrigin}/request/demo`); +const demoIssuer = CredentialIssuer(issuerOrigin); +requestUriEl.textContent = `Request URI: ${requestUri}`; + +function setStatus(message: string, kind: "neutral" | "ok" | "error" = "neutral"): void { + statusEl.textContent = message; + statusEl.classList.toggle("status--error", kind === "error"); + statusEl.classList.toggle("status--ok", kind === "ok"); +} + +function renderCustody(steps: CustodyStep[]): void { + custodyList.replaceChildren( + ...steps.map((step) => { + const li = document.createElement("li"); + li.className = `custody-step custody-step--${step.ok ? "ok" : "fail"}`; + li.innerHTML = ` + +
+
${escapeHtml(step.label)}
+
${escapeHtml(step.detail)}
+
+ `; + return li; + }), + ); +} + +function renderCredentialMeta(validation: MockVerifierResult): void { + const rows: Array<[string, string]> = [ + ["Type (vct)", validation.vct ?? "—"], + ["Issuer (iss)", validation.issuer ?? "—"], + ["Format", validation.format], + ["Holder thumbprint", validation.holderThumbprint ?? "—"], + ]; + if (validation.kb) { + rows.push( + ["kb+jwt nonce", validation.kb.nonce ?? "—"], + ["kb+jwt aud", validation.kb.aud ?? "—"], + ); + } + credentialMeta.innerHTML = rows + .map( + ([dt, dd]) => + `
${escapeHtml(dt)}
${escapeHtml(dd)}
`, + ) + .join(""); +} + +function renderClaims(disclosed: Record): void { + const entries = Object.entries(disclosed); + if (entries.length === 0) { + claimCards.innerHTML = `

No disclosed claim values available.

`; + return; + } + claimCards.replaceChildren( + ...entries.map(([name, value]) => { + const card = document.createElement("article"); + card.className = "claim-card"; + card.innerHTML = ` +

${escapeHtml(name)}

+

${escapeHtml(formatClaimValue(value))}

+ `; + return card; + }), + ); +} + +function formatClaimValue(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "boolean" || typeof value === "number") { + return String(value); + } + return JSON.stringify(value); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderInspection( + presentation: PresentationResult, + validation: MockVerifierResult, +): void { + renderCustody(validation.custody); + renderCredentialMeta(validation); + renderClaims(validation.disclosedClaims); + resultOutput.textContent = JSON.stringify({ presentation, validation }, null, 2); + inspectionEl.hidden = false; +} + +async function resolveIssuerPublicJwk(): Promise { + try { + const res = await fetch(`${issuerOrigin}/jwks`); + if (res.ok) { + const jwks = (await res.json()) as { keys?: JsonWebKey[] }; + if (jwks.keys?.[0]) return jwks.keys[0]; + } + } catch { + // fall through + } + try { + const res = await fetch(`${verifierOrigin}/issuer-jwks`); + if (res.ok) { + const jwks = (await res.json()) as { keys?: JsonWebKey[] }; + if (jwks.keys?.[0]) return jwks.keys[0]; + } + } catch { + // fall through + } + return DEMO_ISSUER_PUBLIC_JWK; +} + +async function loadRequestExpectations(): Promise<{ + nonce?: string; + audience?: string; +}> { + try { + const res = await fetch(`${verifierOrigin}/request/latest`); + if (!res.ok) return {}; + const latest = (await res.json()) as { + nonce?: string; + client_id?: string; + } | null; + if (!latest) return {}; + return { nonce: latest.nonce, audience: latest.client_id }; + } catch { + return {}; + } +} + +async function main(): Promise { + const proxy = await OWSProxy.create(walletContainer, __WALLET_IFRAME_URL__, { + // Branding iframe (ngrok) fetches loopback verifier OID4 endpoints. + allowLocalAccess: true, + }); + + showWalletButton.addEventListener("click", () => { + proxy.showWallet(); + }); + + verifyButton.addEventListener("click", () => { + void (async () => { + verifyButton.disabled = true; + inspectionEl.hidden = true; + setStatus("Requesting credential presentation from wallet…"); + + try { + const presentation = await proxy.credentials.present({ + requestUri, + acceptedIssuers: [demoIssuer], + }); + + const expectations = await loadRequestExpectations(); + const issuerPublicKeyJwk = await resolveIssuerPublicJwk(); + const policy = { + ...MOCK_KYC_POLICY, + allowedIssuers: [demoIssuer], + }; + + const validation = await validateMockPresentation( + presentation, + policy, + demoIssuer, + undefined, + { + expectedNonce: expectations.nonce, + expectedAudience: expectations.audience, + issuerPublicKeyJwk, + }, + ); + + renderInspection(presentation, validation); + + if (validation.valid) { + setStatus( + "Presentation verified — crypto, policy, and custody checks passed.", + "ok", + ); + } else { + setStatus( + `Presentation rejected: ${validation.reasons.join(", ") || "custody check failed"}`, + "error", + ); + } + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Presentation request failed"; + setStatus(message, "error"); + } finally { + verifyButton.disabled = false; + } + })(); + }); + + setStatus("Ready — ensure wallet has a demo KYC credential (use issuer demo first)."); +} + +main().catch((error: unknown) => { + console.error("[ows-example-credential-verifier] failed", error); + setStatus("Failed to start", "error"); +}); diff --git a/examples/credential-verifier/src/oid4vp-plugin.ts b/examples/credential-verifier/src/oid4vp-plugin.ts new file mode 100644 index 0000000..c0f7c07 --- /dev/null +++ b/examples/credential-verifier/src/oid4vp-plugin.ts @@ -0,0 +1,226 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin } from "vite"; +import { applyDemoCors } from "../../shared/src/demo/cors.js"; +import { DEMO_ISSUER_PUBLIC_JWK } from "../../shared/src/demo/demo-keys.js"; +import { + decryptPresentationResponse, + generateVerifierEncryptionKeyPair, +} from "./presentation-jwe.js"; + +type StoredResponse = { + receivedAt: string; + vpToken?: string; + encryptedResponse?: string; + presentationSubmission?: string; +}; + +type StoredRequest = { + client_id: string; + nonce: string; + response_uri: string; + response_mode: string; +}; + +let encryptionKeysPromise: Promise<{ + publicJwk: JsonWebKey; + privateJwk: JsonWebKey; +}> | null = null; + +const lastResponses: StoredResponse[] = []; +let lastRequest: StoredRequest | null = null; + +/** Demo endpoints only — reject oversized POSTs before buffering. */ +const MAX_BODY_BYTES = 1_048_576; + +function ensureKeys() { + // Share one in-flight generation across concurrent callers. Clear only after + // a settled rejection so the next call can retry without duplicate parallel + // keygens racing mid-flight. + if (!encryptionKeysPromise) { + encryptionKeysPromise = generateVerifierEncryptionKeyPair().catch( + (error: unknown) => { + encryptionKeysPromise = null; + throw error; + }, + ); + } + return encryptionKeysPromise; +} + +function readBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (c: Buffer) => { + size += c.length; + if (size > MAX_BODY_BYTES) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + const ct = String(req.headers["content-type"] ?? ""); + // OWS HttpOid4vpClient posts form-urlencoded; also accept JSON for other clients. + if (ct.includes("application/json")) { + try { + resolve(raw ? (JSON.parse(raw) as Record) : {}); + } catch (error) { + reject(error); + } + return; + } + resolve(Object.fromEntries(new URLSearchParams(raw))); + }); + req.on("error", reject); + }); +} + +function asOptionalString(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (value == null) { + return undefined; + } + return JSON.stringify(value); +} + +function sendJson( + res: ServerResponse, + status: number, + body: unknown, +): void { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + applyDemoCors(res); + res.end(JSON.stringify(body)); +} + +/** + * Vite plugin: OID4VP request_uri + response endpoints. + * + * @param options.publicOrigin Stable verifier origin (`https://ows-verifier.com:5176`). + */ +export function oid4vpDemoPlugin(options: { + publicOrigin: string; + issuerJwksUrl?: string; +}): Plugin { + const base = options.publicOrigin.replace(/\/$/, ""); + + return { + name: "ows-oid4vp-demo", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + // OPTIONS + open CORS are handled by demoCorsPlugin (vite.config). + const url = new URL(req.url ?? "/", "http://localhost"); + const path = url.pathname; + + try { + if (req.method === "GET" && path === "/request/demo") { + const keys = await ensureKeys(); + const nonce = `vp-nonce-${Date.now()}`; + const body = { + client_id: base, + nonce, + response_uri: `${base}/response`, + response_mode: "direct_post.jwt", + client_metadata: { + client_name: "OWS Demo Verifier", + require_wallet_attestation: true, + authorization_encrypted_response_alg: "ECDH-ES", + authorization_encrypted_response_enc: "A256GCM", + jwks: { keys: [keys.publicJwk] }, + }, + dcql_query: { + credentials: [ + { + id: "kyc", + format: "vc+sd-jwt", + meta: { vct_values: ["KycCredential"] }, + claims: [ + { path: ["ageOver18"] }, + { path: ["country"] }, + ], + }, + ], + }, + }; + lastRequest = { + client_id: body.client_id, + nonce: body.nonce, + response_uri: body.response_uri, + response_mode: body.response_mode, + }; + sendJson(res, 200, body); + return; + } + + if (req.method === "GET" && path === "/request/latest") { + sendJson(res, 200, lastRequest); + return; + } + + if (req.method === "POST" && path === "/response") { + const body = await readBody(req); + const keys = await ensureKeys(); + let vpToken = asOptionalString(body.vp_token); + const encrypted = asOptionalString(body.response); + if (encrypted && !vpToken) { + vpToken = await decryptPresentationResponse( + encrypted, + keys.privateJwk, + ); + } + lastResponses.unshift({ + receivedAt: new Date().toISOString(), + vpToken, + encryptedResponse: encrypted, + presentationSubmission: asOptionalString( + body.presentation_submission, + ), + }); + if (lastResponses.length > 20) lastResponses.length = 20; + sendJson(res, 200, { status: "ok" }); + return; + } + + if (req.method === "GET" && path === "/response/latest") { + sendJson(res, 200, lastResponses[0] ?? null); + return; + } + + if (req.method === "GET" && path === "/issuer-jwks") { + if (options.issuerJwksUrl) { + try { + const remote = await fetch(options.issuerJwksUrl); + if (remote.ok) { + const jwks = await remote.json(); + sendJson(res, 200, jwks); + return; + } + } catch { + // fall through to embedded demo key + } + } + sendJson(res, 200, { + keys: [{ ...DEMO_ISSUER_PUBLIC_JWK, alg: "EdDSA", use: "sig" }], + }); + return; + } + } catch (error: unknown) { + sendJson(res, 500, { + error: "server_error", + error_description: + error instanceof Error ? error.message : String(error), + }); + return; + } + + next(); + }); + }, + }; +} diff --git a/examples/credential-verifier/src/presentation-jwe.ts b/examples/credential-verifier/src/presentation-jwe.ts new file mode 100644 index 0000000..e9233f6 --- /dev/null +++ b/examples/credential-verifier/src/presentation-jwe.ts @@ -0,0 +1,32 @@ +import * as jose from "jose"; + +const DEFAULT_ALG = "ECDH-ES"; + +/** Decrypt a compact JWE VP response (`direct_post.jwt`). */ +export async function decryptPresentationResponse( + jwe: string, + privateJwk: JsonWebKey, +): Promise { + const key = await jose.importJWK(privateJwk); + const { plaintext } = await jose.compactDecrypt(jwe, key); + return new TextDecoder().decode(plaintext); +} + +/** Generate an ephemeral P-256 key pair for verifier encryption JWKS. */ +export async function generateVerifierEncryptionKeyPair(): Promise<{ + publicJwk: JsonWebKey; + privateJwk: JsonWebKey; +}> { + const { publicKey, privateKey } = await jose.generateKeyPair("ECDH-ES", { + crv: "P-256", + extractable: true, + }); + const publicJwk = await jose.exportJWK(publicKey); + const privateJwk = await jose.exportJWK(privateKey); + publicJwk.alg = DEFAULT_ALG; + publicJwk.use = "enc"; + publicJwk.kid = publicJwk.kid ?? "ows-verifier-enc-1"; + privateJwk.alg = DEFAULT_ALG; + privateJwk.kid = publicJwk.kid; + return { publicJwk, privateJwk }; +} diff --git a/examples/credential-verifier/src/styles.css b/examples/credential-verifier/src/styles.css new file mode 100644 index 0000000..755fd41 --- /dev/null +++ b/examples/credential-verifier/src/styles.css @@ -0,0 +1,161 @@ +.site-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid #8884; +} + +.subtitle { + margin: 0.25rem 0 0; + opacity: 0.8; + font-size: 0.9rem; +} + +.content { + padding: 1.25rem; + max-width: 42rem; + display: grid; + gap: 1.5rem; +} + +.panel { + display: grid; + gap: 0.75rem; +} + +.mono { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + word-break: break-all; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +button { + padding: 0.5rem 0.875rem; + font: inherit; + cursor: pointer; +} + +.status--error { + color: #a11; +} + +.status--ok { + color: #176; +} + +.section-title { + margin: 0 0 0.5rem; + font-size: 1rem; +} + +.inspection { + display: grid; + gap: 1.25rem; +} + +.custody-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.5rem; +} + +.custody-step { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 0.75rem; + padding: 0.65rem 0.75rem; + border: 1px solid #8884; + border-radius: 6px; + align-items: start; +} + +.custody-step__mark { + font-weight: 700; + font-family: ui-monospace, monospace; +} + +.custody-step--ok .custody-step__mark { + color: #176; +} + +.custody-step--fail .custody-step__mark { + color: #a11; +} + +.custody-step__label { + font-weight: 600; +} + +.custody-step__detail { + grid-column: 2; + font-size: 0.85rem; + opacity: 0.85; + word-break: break-word; +} + +.credential-meta { + display: grid; + gap: 0.35rem; + padding: 0.75rem; + border: 1px solid #8884; + border-radius: 6px; + font-size: 0.9rem; +} + +.credential-meta dt { + font-weight: 600; + display: inline; +} + +.credential-meta dd { + display: inline; + margin: 0 0 0 0.35rem; + font-family: ui-monospace, monospace; + word-break: break-all; +} + +.claim-cards { + display: grid; + gap: 0.5rem; +} + +.claim-card { + padding: 0.75rem; + border: 1px solid #8884; + border-radius: 6px; +} + +.claim-card__name { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; + margin: 0 0 0.25rem; +} + +.claim-card__value { + margin: 0; + font-family: ui-monospace, monospace; + font-size: 0.95rem; + word-break: break-word; +} + +.raw-details summary { + cursor: pointer; + margin-bottom: 0.5rem; +} + +.output { + padding: 0.75rem; + background: #8881; + border-radius: 6px; + overflow: auto; + font-size: 0.75rem; + max-height: 18rem; +} diff --git a/examples/credential-verifier/tsconfig.json b/examples/credential-verifier/tsconfig.json new file mode 100644 index 0000000..08ec0d4 --- /dev/null +++ b/examples/credential-verifier/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@ows-shared": ["../shared/src/index.ts"] + } + }, + "include": ["src/**/*", "../shared/src/**/*"] +} diff --git a/examples/credential-verifier/vite.config.mjs b/examples/credential-verifier/vite.config.mjs new file mode 100644 index 0000000..7d3aa13 --- /dev/null +++ b/examples/credential-verifier/vite.config.mjs @@ -0,0 +1,79 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import { defineConfig } from "vite"; +import { + demoPublicOrigin, + OWS_ISSUER_HOSTNAME, + OWS_ISSUER_PORT, + OWS_VERIFIER_HOSTNAME, + OWS_VERIFIER_PORT, + resolveHttpsOptions, + walletIframeUrl, +} from "../host-dev.mjs"; +import { demoCorsPlugin } from "../shared/src/demo/cors.ts"; +import { oid4vpDemoPlugin } from "./src/oid4vp-plugin.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const https = resolveHttpsOptions({ + certsDir: path.resolve(__dirname, "certs"), + exampleLabel: "examples/credential-verifier", +}); + +const port = Number(process.env.PORT ?? OWS_VERIFIER_PORT); +const scheme = https ? "https" : "http"; +const issuerPort = Number(process.env.ISSUER_PORT ?? OWS_ISSUER_PORT); +const issuerOrigin = demoPublicOrigin({ + hostname: OWS_ISSUER_HOSTNAME, + port: issuerPort, + scheme, + envValue: process.env.ISSUER_ORIGIN, +}); +const verifierOrigin = demoPublicOrigin({ + hostname: OWS_VERIFIER_HOSTNAME, + port, + scheme, + envValue: process.env.VERIFIER_ORIGIN, +}); + +export default defineConfig({ + define: { + __WALLET_IFRAME_URL__: JSON.stringify(walletIframeUrl()), + __VERIFIER_ORIGIN__: JSON.stringify(verifierOrigin), + __ISSUER_ORIGIN__: JSON.stringify(issuerOrigin), + }, + resolve: { + alias: { + "@ows-shared": path.resolve(__dirname, "../shared/src/index.ts"), + }, + }, + plugins: [ + demoCorsPlugin(), + oid4vpDemoPlugin({ + publicOrigin: verifierOrigin, + issuerJwksUrl: `${issuerOrigin}/jwks`, + }), + ], + server: { + port, + host: "0.0.0.0", + allowedHosts: true, + // Disable Vite default CORS (localhost-only). demoCorsPlugin allows *. + cors: false, + ...(https ? { https } : {}), + }, + preview: { + port, + host: "0.0.0.0", + cors: false, + ...(https ? { https } : {}), + }, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + }, +}); diff --git a/examples/general-wallet/README.md b/examples/general-wallet/README.md new file mode 100644 index 0000000..eff962e --- /dev/null +++ b/examples/general-wallet/README.md @@ -0,0 +1,65 @@ +# OWS Example General Wallet + +Reference **general-purpose Branding Layer** for the Open Wallet Standard — a MetaMask-style wallet in the browser. Serves the branding app and Signing Layer on **one origin** (required for WebAuthn `rpId` and signer nesting). + +Built with **Vite + React + TypeScript + Tailwind CSS**. + +## Stack + +``` +Host Layer (ows-example-host) + └── This example (Branding Layer) @1shotapi/ows-wallet-utils ↔ host + └── Signing Layer @1shotapi/ows-signer-utils ↔ signer +``` + +| Path | Content | +|------|---------| +| `/wallet/` | Vite React app — `OWSWallet` + UI via `WalletProvider` | +| `/signer/` | Static `@1shotapi/ows-signer` ES modules (not bundled) | + +## Setup + +1. From repo root: `npm install && npm run build` +2. Copy [`.env.example`](../../.env.example) to `.env` at repo root and set: + - `NGROK_AUTHTOKEN` ([ngrok dashboard](https://dashboard.ngrok.com/get-started/your-authtoken)) + - `NGROK_DOMAIN` — optional reserved domain from [ngrok Domains](https://dashboard.ngrok.com/domains) (e.g. `immune-sheep-light.ngrok-free.app`) for a stable URL + +## Run + +```bash +# Branding Layer + Signing Layer + ngrok (from repo root) +npm run dev:general-wallet + +# Local HTTP only (no tunnel) +npm run dev:local -w @1shotapi/ows-example-general-wallet +``` + +| Service | Local URL | +|---------|-----------| +| Branding Layer (`/wallet/`) | http://localhost:5174/wallet/ | +| Signing Layer (`/signer/`) | http://localhost:5174/signer/ | + +When ngrok starts, the script prints HTTPS URLs for the branding iframe. Host demos read `WALLET_IFRAME_URL` (full override) or `NGROK_DOMAIN` from repo root `.env`. + +## Full E2E (with host) + +```bash +# Terminal 1 — Branding Layer + Signing Layer +npm run dev:general-wallet + +# Terminal 2 — Host Layer (reads WALLET_IFRAME_URL / NGROK_DOMAIN from repo root .env) +npm run dev -w @1shotapi/ows-example-host +``` + +Passkeys require HTTPS — use the ngrok branding URL for cross-origin host testing. + +## Architecture + +- `WalletProvider` boots `OWSSigner` + `OWSWallet`, registers EIP-1193 / credentials handlers, and owns unlock / address / chain state. +- Shared `Modal` + modal queue drive connect, sign, credential consent, backup/restore (Signing Layer ceremony panel via `OWSSigner`). +- Protocol wiring lives under `src/ows/`; UI under `src/components/`. +- Demo analytics (`src/analytics/`): branding-owned `AccountCreatedEvent` / `PersonalSignEvent` emit via `wallet.analytics.emit` so the host `proxy.analytics` panel can show them. + +## Status + +React rewrite with passkey create/unlock, EIP-191 `personal_sign` / typed data, `RpcHelper` / `SignHelper`, credentials, backup/restore, and EVM/Solana address display. diff --git a/examples/general-wallet/index.html b/examples/general-wallet/index.html new file mode 100644 index 0000000..6cfa48b --- /dev/null +++ b/examples/general-wallet/index.html @@ -0,0 +1,12 @@ + + + + + + OWS — Example General Wallet + + +
+ + + diff --git a/examples/general-wallet/package.json b/examples/general-wallet/package.json new file mode 100644 index 0000000..368a7d7 --- /dev/null +++ b/examples/general-wallet/package.json @@ -0,0 +1,35 @@ +{ + "name": "@1shotapi/ows-example-general-wallet", + "version": "0.0.0", + "private": true, + "description": "OWS reference general-purpose Branding Layer — React + Vite wallet with Signing Layer", + "type": "module", + "scripts": { + "dev": "node scripts/dev.mjs", + "dev:local": "node scripts/dev.mjs --no-tunnel", + "build": "tsc -p tsconfig.json --noEmit && vite build && node scripts/copy-signer.mjs", + "clean": "node scripts/clean.mjs", + "lint": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@1shotapi/ows-oid4": "*", + "@1shotapi/ows-signer-utils": "*", + "@1shotapi/ows-types": "*", + "@1shotapi/ows-wallet-utils": "*", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "viem": "^2.52.0" + }, + "devDependencies": { + "@ngrok/ngrok": "^1.7.0", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "dotenv": "^16.4.7", + "tailwindcss": "^4.1.11", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } +} diff --git a/examples/general-wallet/scripts/clean.mjs b/examples/general-wallet/scripts/clean.mjs new file mode 100644 index 0000000..26d0615 --- /dev/null +++ b/examples/general-wallet/scripts/clean.mjs @@ -0,0 +1,8 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.resolve(__dirname, "../dist"); +fs.rmSync(dist, { recursive: true, force: true }); +console.log("Removed dist/"); diff --git a/examples/general-wallet/scripts/copy-signer.mjs b/examples/general-wallet/scripts/copy-signer.mjs new file mode 100644 index 0000000..84a9537 --- /dev/null +++ b/examples/general-wallet/scripts/copy-signer.mjs @@ -0,0 +1,16 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const exampleRoot = path.resolve(__dirname, ".."); +const signerPkgSrc = path.resolve(exampleRoot, "../../packages/ows-signer/src"); +const signerPublic = path.join(exampleRoot, "signer-static/index.html"); +const outSigner = path.join(exampleRoot, "dist/signer"); + +fs.rmSync(outSigner, { recursive: true, force: true }); +fs.mkdirSync(path.join(outSigner, "src"), { recursive: true }); +fs.cpSync(signerPkgSrc, path.join(outSigner, "src"), { recursive: true }); +fs.copyFileSync(signerPublic, path.join(outSigner, "index.html")); + +console.log("Copied Signing Layer to dist/signer/"); diff --git a/examples/general-wallet/scripts/dev.mjs b/examples/general-wallet/scripts/dev.mjs new file mode 100644 index 0000000..6729ecb --- /dev/null +++ b/examples/general-wallet/scripts/dev.mjs @@ -0,0 +1,141 @@ +/** + * Start Vite for Branding Layer + Signing Layer, optionally expose via ngrok. + * Loads NGROK_AUTHTOKEN from repo root .env (copy from .env.example). + * + * Usage: + * node scripts/dev.mjs # dev server + ngrok tunnel + * node scripts/dev.mjs --no-tunnel # local HTTP only + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; +import dotenv from "dotenv"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const preferredPort = Number(process.env.PORT ?? 5174); +const noTunnel = process.argv.includes("--no-tunnel"); +let viteServer; +let ngrokListener; +let shuttingDown = false; +/** Port Vite actually bound (must match ngrok `addr`). */ +let listenPort = preferredPort; + +async function startDevServer() { + viteServer = await createServer({ + configFile: path.join(__dirname, "../vite.config.ts"), + server: { + port: preferredPort, + strictPort: true, + host: "0.0.0.0", + }, + }); + await viteServer.listen(); + + const address = viteServer.httpServer?.address(); + if (address && typeof address === "object") { + listenPort = address.port; + } else { + listenPort = preferredPort; + } +} + +async function startTunnel() { + const ngrok = (await import("@ngrok/ngrok")).default; + const domain = normalizeNgrokDomain(process.env.NGROK_DOMAIN); + const options = { + addr: listenPort, + authtoken_from_env: true, + }; + if (domain) { + options.domain = domain; + } + ngrokListener = await ngrok.forward(options); + return ngrokListener.url(); +} + +/** Hostname only — accepts `immune-sheep-light.ngrok-free.app` or a full URL. */ +function normalizeNgrokDomain(value) { + const raw = value?.trim(); + if (!raw) return undefined; + try { + const url = raw.includes("://") ? raw : `https://${raw}`; + return new URL(url).hostname; + } catch { + return raw.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + } +} + +function printUrls(tunnelUrl) { + const localWallet = `http://localhost:${listenPort}/wallet/`; + const localSigner = `http://localhost:${listenPort}/signer/`; + + console.log(`OWS general-wallet dev server: http://localhost:${listenPort}`); + console.log(` Branding Layer (local): ${localWallet}`); + console.log(` Signing Layer (local): ${localSigner}`); + + if (tunnelUrl) { + const walletUrl = new URL("/wallet/", tunnelUrl).href; + const signerUrl = new URL("/signer/", tunnelUrl).href; + console.log(` Branding Layer (ngrok): ${walletUrl}`); + console.log(` Signing Layer (ngrok): ${signerUrl}`); + console.log(` Host env: WALLET_IFRAME_URL=${walletUrl}`); + } +} + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + + try { + if (ngrokListener) { + await ngrokListener.close(); + } + if (viteServer) { + await viteServer.close(); + } + } catch (error) { + console.error(`Failed to shut down (${signal}):`, error); + process.exit(1); + return; + } + + process.exit(0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +try { + await startDevServer(); + + let tunnelUrl; + if (!noTunnel) { + if (!process.env.NGROK_AUTHTOKEN) { + console.warn( + "NGROK_AUTHTOKEN not set — running local only. Copy .env.example to .env at repo root.", + ); + } else { + tunnelUrl = await startTunnel(); + } + } + + printUrls(tunnelUrl); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/Port .* is already in use|EADDRINUSE/i.test(message)) { + console.error( + `Port ${preferredPort} is already in use. Stop the other general-wallet ` + + `(or anything on that port), then retry. A leftover \`dev:local\` / ` + + `\`--no-tunnel\` process will steal ngrok traffic and break the host handshake.`, + ); + } + console.error("Failed to start wallet dev server:", error); + process.exit(1); +} diff --git a/examples/general-wallet/signer-static/index.html b/examples/general-wallet/signer-static/index.html new file mode 100644 index 0000000..d7fdea8 --- /dev/null +++ b/examples/general-wallet/signer-static/index.html @@ -0,0 +1,54 @@ + + + + + + OWS Custody Signer + + + +
+ + + diff --git a/examples/general-wallet/src/App.tsx b/examples/general-wallet/src/App.tsx new file mode 100644 index 0000000..6a1319d --- /dev/null +++ b/examples/general-wallet/src/App.tsx @@ -0,0 +1,35 @@ +import { useWallet } from "./wallet/WalletProvider"; +import { WalletChrome } from "./components/WalletChrome"; +import { OnboardingPanel } from "./components/OnboardingPanel"; +import { MainPanel } from "./components/MainPanel"; +import { SignerHost } from "./components/SignerHost"; +import { ModalHost } from "./components/ModalHost"; + +export function App() { + const { bootError, ready, unlocked, walletCreated, embedded } = useWallet(); + + const showOnboarding = embedded && !walletCreated && !unlocked; + + return ( +
+ {embedded ? : null} + +
+ {bootError ? ( +

+ Failed to start: {bootError} +

+ ) : !ready ? ( +

Loading…

+ ) : showOnboarding ? ( + + ) : ( + + )} +
+ + + +
+ ); +} diff --git a/examples/general-wallet/src/analytics/events.ts b/examples/general-wallet/src/analytics/events.ts new file mode 100644 index 0000000..a823969 --- /dev/null +++ b/examples/general-wallet/src/analytics/events.ts @@ -0,0 +1,29 @@ +import { + DomainString, + EVMAccountAddress, + OWSAnalyticsEvent, +} from "@1shotapi/ows-types"; + +/** + * Demo branding analytics events (not part of OWS). Hosts narrow on `name`. + */ + +export class AccountCreatedEvent extends OWSAnalyticsEvent { + constructor( + hostDomain: DomainString, + public readonly accountAddress: EVMAccountAddress, + ) { + super("AccountCreated", hostDomain); + } +} + +export class PersonalSignEvent extends OWSAnalyticsEvent { + constructor( + hostDomain: DomainString, + public readonly accountAddress: EVMAccountAddress, + public readonly messageLength: number, + public readonly durationMs: number, + ) { + super("PersonalSign", hostDomain); + } +} diff --git a/examples/general-wallet/src/analytics/hostDomain.ts b/examples/general-wallet/src/analytics/hostDomain.ts new file mode 100644 index 0000000..ca1c40d --- /dev/null +++ b/examples/general-wallet/src/analytics/hostDomain.ts @@ -0,0 +1,30 @@ +import { DomainString } from "@1shotapi/ows-types"; + +/** + * Best-effort host page hostname for analytics `hostDomain`. + * Prefers the embedding ancestor / referrer when in an iframe. + */ +export function resolveHostDomain(): DomainString { + if (typeof window === "undefined") { + return DomainString("localhost"); + } + + const ancestors = window.location.ancestorOrigins; + if (ancestors && ancestors.length > 0) { + try { + return DomainString(new URL(ancestors[ancestors.length - 1]!).hostname); + } catch { + // fall through + } + } + + if (document.referrer) { + try { + return DomainString(new URL(document.referrer).hostname); + } catch { + // fall through + } + } + + return DomainString(window.location.hostname || "localhost"); +} diff --git a/examples/general-wallet/src/components/MainPanel.tsx b/examples/general-wallet/src/components/MainPanel.tsx new file mode 100644 index 0000000..fa8e918 --- /dev/null +++ b/examples/general-wallet/src/components/MainPanel.tsx @@ -0,0 +1,116 @@ +import { useWallet } from "../wallet/WalletProvider"; + +export function MainPanel() { + const { + unlocked, + walletCreated, + evmAddress, + solanaAddress, + chainId, + chains, + credentialCount, + switchChain, + openCreateBackup, + openRestoreBackup, + openCredentialList, + } = useWallet(); + + const status = unlocked + ? "Unlocked" + : walletCreated + ? "Created (locked)" + : "Not created"; + + return ( + <> +

OWS Example General Wallet

+

+ Branding Layer: general-purpose wallet with nested Signing Layer. +

+ +
+
+ Status + {status} +
+
+ EVM + {evmAddress} +
+
+ Solana + + {solanaAddress} + +
+
+ + +
+
+ Credentials + {credentialCount} +
+ +
+ {unlocked ? ( + + ) : null} + {!unlocked ? ( + + ) : null} + +
+
+ + ); +} diff --git a/examples/general-wallet/src/components/Modal.tsx b/examples/general-wallet/src/components/Modal.tsx new file mode 100644 index 0000000..755b866 --- /dev/null +++ b/examples/general-wallet/src/components/Modal.tsx @@ -0,0 +1,126 @@ +import type { ReactNode } from "react"; +import { useEffect, useId, useRef } from "react"; + +export type ModalAction = { + /** Stable list key; defaults to label + variant when omitted. */ + id?: string; + label: string; + onClick: () => void; + variant?: "primary" | "secondary"; + autoFocus?: boolean; + disabled?: boolean; +}; + +export type ModalProps = { + title: string; + children: ReactNode; + actions?: ModalAction[]; + onBackdropDismiss?: () => void; + /** Extra content after children (e.g. signer slot). */ + footer?: ReactNode; + wide?: boolean; +}; + +/** + * Consent / status panel. Uses a modeless `` (`show()`, not + * `showModal()`) so it stays in the normal stacking context below the Signing + * Layer iframe (`z-index: 10001`). A top-layer modal would cover Confirm / + * passphrase and stall the ceremony. + */ +export function Modal({ + title, + children, + actions, + onBackdropDismiss, + footer, + wide, +}: ModalProps) { + const titleId = useId(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (!dialog.open) { + dialog.show(); + } + + const focusTarget = + dialog.querySelector("[data-autofocus]") ?? + dialog.querySelector( + "button, [href], input, select, textarea", + ); + focusTarget?.focus(); + + const onCancel = (event: Event) => { + // Escape — only dismiss when the host offered a dismiss handler. + if (!onBackdropDismiss) { + event.preventDefault(); + return; + } + onBackdropDismiss(); + }; + + dialog.addEventListener("cancel", onCancel); + return () => { + dialog.removeEventListener("cancel", onCancel); + if (dialog.open) { + dialog.close(); + } + }; + }, [onBackdropDismiss]); + + return ( + + {onBackdropDismiss ? ( + + ))} + + ) : null} + + + ); +} diff --git a/examples/general-wallet/src/components/ModalHost.tsx b/examples/general-wallet/src/components/ModalHost.tsx new file mode 100644 index 0000000..003715f --- /dev/null +++ b/examples/general-wallet/src/components/ModalHost.tsx @@ -0,0 +1,91 @@ +import { useWallet } from "../wallet/WalletProvider"; +import { + ConnectModal, + PasskeyNameModal, + WalletSetupModal, +} from "./modals/SetupModals"; +import { + PersonalSignModal, + SendTransactionModal, + TypedDataModal, +} from "./modals/SignModals"; +import { + CredentialListModal, + CredentialOfferModal, + CredentialPresentationModal, +} from "./modals/CredentialModals"; +import { CreateBackupModal, RestoreBackupModal } from "./modals/BackupModals"; + +export function ModalHost() { + const { activeModal } = useWallet(); + if (!activeModal) return null; + + switch (activeModal.kind) { + case "walletSetup": + return ; + case "passkeyName": + return ; + case "connect": + return ; + case "personalSign": + return ( + + ); + case "typedData": + return ( + + ); + case "sendTransaction": + return ( + + ); + case "credentialOffer": + return ( + + ); + case "credentialPresentation": + return ( + + ); + case "credentialList": + return ( + + ); + case "createBackup": + return ( + + ); + case "restoreBackup": + return ( + + ); + default: + return null; + } +} diff --git a/examples/general-wallet/src/components/OnboardingPanel.tsx b/examples/general-wallet/src/components/OnboardingPanel.tsx new file mode 100644 index 0000000..0b3cc29 --- /dev/null +++ b/examples/general-wallet/src/components/OnboardingPanel.tsx @@ -0,0 +1,39 @@ +import { useWallet } from "../wallet/WalletProvider"; + +export function OnboardingPanel() { + const { loginWithPasskey, createNewWalletFromUi } = useWallet(); + + return ( +
+

Welcome

+

+ Log in with an existing passkey or create a new wallet account to get + started. +

+
+ + +
+
+ ); +} diff --git a/examples/general-wallet/src/components/SignerHost.tsx b/examples/general-wallet/src/components/SignerHost.tsx new file mode 100644 index 0000000..2bc1eda --- /dev/null +++ b/examples/general-wallet/src/components/SignerHost.tsx @@ -0,0 +1,7 @@ +import { useWallet } from "../wallet/WalletProvider"; + +/** Hidden mount point for the Signing Layer iframe. */ +export function SignerHost() { + const { signerContainerRef } = useWallet(); + return
; +} diff --git a/examples/general-wallet/src/components/WalletChrome.tsx b/examples/general-wallet/src/components/WalletChrome.tsx new file mode 100644 index 0000000..fbd3638 --- /dev/null +++ b/examples/general-wallet/src/components/WalletChrome.tsx @@ -0,0 +1,24 @@ +import { useWallet } from "../wallet/WalletProvider"; + +export function WalletChrome() { + const { requestHide } = useWallet(); + + return ( +
+

Open Wallet

+ +
+ ); +} diff --git a/examples/general-wallet/src/components/modals/BackupModals.tsx b/examples/general-wallet/src/components/modals/BackupModals.tsx new file mode 100644 index 0000000..20b42d8 --- /dev/null +++ b/examples/general-wallet/src/components/modals/BackupModals.tsx @@ -0,0 +1,307 @@ +import { useEffect, useRef, useState } from "react"; +import { + OwsUserRejectedError, + type RecoveryDataCreatedData, +} from "@1shotapi/ows-types"; +import { Modal } from "../Modal"; +import { useWallet } from "../../wallet/WalletProvider"; + +const DEFAULT_MIN_PASSWORD_LENGTH = 12; + +function formatBackupError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + if (message.includes("passwordTooShort")) { + return "Passphrase is too short. Try again."; + } + if ( + error.name === "OwsSignDeniedError" || + message.includes("signDenied") || + message.includes("SignDenied") + ) { + return "Backup was cancelled."; + } + if (message.includes("NotAllowed") || message.includes("not allowed")) { + return "Passkey prompt was cancelled or blocked."; + } + return message || "Backup failed."; + } + return "Backup failed."; +} + +export function CreateBackupModal({ + onResolve, + onReject, +}: { + onResolve: () => void; + onReject: (error: unknown) => void; +}) { + const { getSigner, ensureReady, persistBackup } = useWallet(); + const [phase, setPhase] = useState<"prompt" | "result" | "error">("prompt"); + const [error, setError] = useState(null); + const [failure, setFailure] = useState(null); + const [result, setResult] = useState(null); + const [copyLabel, setCopyLabel] = useState("Copy"); + const abortedRef = useRef(false); + const copyResetTimeoutRef = useRef | null>( + null, + ); + + useEffect(() => { + abortedRef.current = false; + + void (async () => { + try { + await ensureReady(); + if (abortedRef.current) return; + + const signer = getSigner(); + if (!signer) { + throw new Error("Signer not ready for backup"); + } + + const created = await signer.createRecoveryData( + `Passphrase (min ${DEFAULT_MIN_PASSWORD_LENGTH} characters)`, + "Continue", + DEFAULT_MIN_PASSWORD_LENGTH, + { + explanationHeader: "Confirm backup", + explanationText: + "Your device will ask for a passkey to encrypt this backup.", + }, + ); + + if (abortedRef.current) return; + + await persistBackup(created.encryptedPrivateKey); + if (abortedRef.current) return; + + setResult(created); + setPhase("result"); + } catch (err) { + if (abortedRef.current) return; + setFailure(err); + setError(formatBackupError(err)); + setPhase("error"); + } + })(); + + return () => { + abortedRef.current = true; + if (copyResetTimeoutRef.current !== null) { + clearTimeout(copyResetTimeoutRef.current); + copyResetTimeoutRef.current = null; + } + }; + }, [ensureReady, getSigner, persistBackup]); + + const rejectFailure = () => { + onReject( + failure ?? + new OwsUserRejectedError(error ?? "Backup failed."), + ); + }; + + const rejectCancelled = () => { + onReject(new OwsUserRejectedError("User cancelled backup creation")); + }; + + if (phase === "result" && result) { + return ( + { + void navigator.clipboard.writeText(result.encryptedPrivateKey).then( + () => { + if (abortedRef.current) return; + setCopyLabel("Copied"); + if (copyResetTimeoutRef.current !== null) { + clearTimeout(copyResetTimeoutRef.current); + } + copyResetTimeoutRef.current = setTimeout(() => { + copyResetTimeoutRef.current = null; + if (!abortedRef.current) { + setCopyLabel("Copy"); + } + }, 1500); + }, + () => { + if (!abortedRef.current) setCopyLabel("Copy failed"); + }, + ); + }, + }, + { + label: "Done", + variant: "primary", + autoFocus: true, + onClick: onResolve, + }, + ]} + > +

+ Encrypted backup +

+
+          {result.encryptedPrivateKey}
+        
+
+ ); + } + + return ( + + {phase === "prompt" ? ( +

+ Enter a passphrase of at least {DEFAULT_MIN_PASSWORD_LENGTH}{" "} + characters to encrypt your private key. Store the backup somewhere + safe; you will need it to restore your wallet. +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function formatRestoreError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + if ( + message.includes("decryptionFailed") || + message.includes("decrypt") || + message.includes("OperationError") + ) { + return "Could not decrypt the backup. Check the passphrase and try again."; + } + if ( + error.name === "OwsSignDeniedError" || + message.includes("signDenied") || + message.includes("SignDenied") + ) { + return "Restore was cancelled."; + } + if (message.includes("NotAllowed") || message.includes("not allowed")) { + return "Passkey prompt was cancelled or blocked."; + } + return message || "Restore failed."; + } + return "Restore failed."; +} + +export function RestoreBackupModal({ + encryptedPrivateKey, + onResolve, +}: { + encryptedPrivateKey: string; + onResolve: (restored: boolean) => void; +}) { + const { getSigner, awaitSignerReady } = useWallet(); + const [phase, setPhase] = useState<"prompt" | "done" | "error">("prompt"); + const [error, setError] = useState(null); + const abortedRef = useRef(false); + + useEffect(() => { + abortedRef.current = false; + + void (async () => { + try { + await awaitSignerReady(); + if (abortedRef.current) return; + + const signer = getSigner(); + if (!signer) { + throw new Error("Signer not ready for restore"); + } + + await signer.recoverKey( + encryptedPrivateKey, + "Backup passphrase", + "Restore", + { + explanationHeader: "Confirm restore", + explanationText: + "Your device may ask for a passkey after you enter the passphrase.", + }, + ); + if (abortedRef.current) return; + setPhase("done"); + } catch (err) { + if (abortedRef.current) return; + setError(formatRestoreError(err)); + setPhase("error"); + } + })(); + + return () => { + abortedRef.current = true; + }; + }, [awaitSignerReady, encryptedPrivateKey, getSigner]); + + return ( + onResolve(false) : undefined + } + actions={ + phase === "done" + ? [ + { + label: "Done", + variant: "primary", + autoFocus: true, + onClick: () => onResolve(true), + }, + ] + : [ + { + label: phase === "error" ? "Close" : "Cancel", + variant: "secondary", + autoFocus: phase === "error", + onClick: () => onResolve(false), + }, + ] + } + > + {phase === "prompt" ? ( +

+ Enter the passphrase you used when creating this backup to unlock your + wallet. +

+ ) : null} + {phase === "done" ? ( +

+ Wallet restored. You can sign until this tab is closed. +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/examples/general-wallet/src/components/modals/CredentialModals.tsx b/examples/general-wallet/src/components/modals/CredentialModals.tsx new file mode 100644 index 0000000..f0a808c --- /dev/null +++ b/examples/general-wallet/src/components/modals/CredentialModals.tsx @@ -0,0 +1,147 @@ +import type { + CredentialOfferApprovalRequest, + CredentialPresentationApprovalRequest, + CredentialSummary, +} from "@1shotapi/ows-types"; +import { Modal } from "../Modal"; + +export function CredentialOfferModal({ + request, + onResolve, +}: { + request: CredentialOfferApprovalRequest; + onResolve: (approved: boolean) => void; +}) { + return ( + onResolve(false)} + actions={[ + { + label: "Reject", + variant: "secondary", + onClick: () => onResolve(false), + }, + { + label: "Accept", + variant: "primary", + autoFocus: true, + onClick: () => onResolve(true), + }, + ]} + > +

+ {request.issuerName} ({request.issuerId}) wants to issue a credential to + your wallet. +

+

Offered credentials:

+
    + {request.offeredCredentials.map((credential) => { + const scope = credential.scope ? ` — ${credential.scope}` : ""; + return ( +
  • + {credential.configurationId} ({credential.format}) + {scope} +
  • + ); + })} +
+

+ You may be asked to verify with your passkey after you continue. +

+
+ ); +} + +export function CredentialPresentationModal({ + request, + onResolve, +}: { + request: CredentialPresentationApprovalRequest; + onResolve: (approved: boolean) => void; +}) { + return ( + onResolve(false)} + actions={[ + { + label: "Reject", + variant: "secondary", + onClick: () => onResolve(false), + }, + { + label: "Share", + variant: "primary", + autoFocus: true, + onClick: () => onResolve(true), + }, + ]} + > +

+ {request.verifierName} ({request.verifierId}) is requesting proof. +

+

+ Credential: {request.credentialType} from {request.credentialIssuer} +

+

Claims to disclose:

+
    + {request.requestedClaims.map((claim) => ( +
  • {claim}
  • + ))} +
+

+ You may be asked to verify with your passkey after you continue. +

+
+ ); +} + +export function CredentialListModal({ + credentials, + onResolve, +}: { + credentials: CredentialSummary[]; + onResolve: () => void; +}) { + return ( + + {credentials.length === 0 ? ( +

+ No credentials stored in this wallet yet. +

+ ) : ( +
    + {credentials.map((credential) => ( +
  • +

    + {credential.type.join(", ")} +

    +

    Issuer: {credential.issuer}

    +

    + {credential.validUntil + ? `Issued ${credential.issuedAt} · Valid until ${credential.validUntil}` + : `Issued ${credential.issuedAt}`} +

    +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/general-wallet/src/components/modals/SetupModals.tsx b/examples/general-wallet/src/components/modals/SetupModals.tsx new file mode 100644 index 0000000..0fd6a34 --- /dev/null +++ b/examples/general-wallet/src/components/modals/SetupModals.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { Modal } from "../Modal"; +import type { WalletSetupChoice } from "../../wallet/modalTypes"; + +export function WalletSetupModal({ + onResolve, +}: { + onResolve: (choice: WalletSetupChoice) => void; +}) { + return ( + onResolve("cancel")} + actions={[ + { + label: "Cancel", + variant: "secondary", + onClick: () => onResolve("cancel"), + }, + { + label: "Login with passkey", + variant: "primary", + autoFocus: true, + onClick: () => onResolve("login"), + }, + { + label: "Create account", + variant: "primary", + onClick: () => onResolve("create"), + }, + ]} + > +

+ This wallet uses a passkey to secure your keys on this device. Log in + with an existing passkey or create a new account before continuing. +

+
+ ); +} + +export function PasskeyNameModal({ + onResolve, +}: { + onResolve: (name: string | null) => void; +}) { + const [name, setName] = useState(""); + const [error, setError] = useState(null); + + const submit = () => { + const trimmed = name.trim(); + if (!trimmed) { + setError("Enter a name for your passkey."); + return; + } + onResolve(trimmed); + }; + + return ( + onResolve(null)} + actions={[ + { + label: "Cancel", + variant: "secondary", + onClick: () => onResolve(null), + }, + { + label: "Continue", + variant: "primary", + onClick: submit, + }, + ]} + > +

+ Choose a name for this wallet passkey. Your device will use it when you + create the credential and when you sign in later. +

+ + { + setName(event.target.value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } + }} + className="mb-2 box-border w-full rounded-md border border-[color-mix(in_srgb,CanvasText_25%,transparent)] bg-[Canvas] px-2.5 py-2 text-inherit" + /> + {error ?

{error}

: null} +
+ ); +} + +export function ConnectModal({ + onResolve, +}: { + onResolve: (approved: boolean) => void; +}) { + return ( + onResolve(false)} + actions={[ + { + label: "Reject", + variant: "secondary", + onClick: () => onResolve(false), + }, + { + label: "Continue", + variant: "primary", + autoFocus: true, + onClick: () => onResolve(true), + }, + ]} + > +

+ The connected app is requesting your wallet address. You may be asked to + verify with your passkey after you continue. +

+
+ ); +} diff --git a/examples/general-wallet/src/components/modals/SignModals.tsx b/examples/general-wallet/src/components/modals/SignModals.tsx new file mode 100644 index 0000000..6b293ed --- /dev/null +++ b/examples/general-wallet/src/components/modals/SignModals.tsx @@ -0,0 +1,267 @@ +import type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, +} from "@1shotapi/ows-signer-utils"; +import { + ConversionUtils, + HexString, + OwsUserRejectedError, + type EVMSignatureHex, +} from "@1shotapi/ows-types"; +import { useRef, useState } from "react"; +import type { TypedDataDefinition } from "viem"; +import { useWallet } from "../../wallet/WalletProvider"; +import { Modal } from "../Modal"; + +function isSignDenied(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.name === "OwsSignDeniedError" || + error.message.includes("signDenied") || + error.message.includes("SignDenied") || + error.message.includes("NotAllowed") || + error.message.includes("not allowed") + ); +} + +export function PersonalSignModal({ + request, + onResolve, + onReject, +}: { + request: PersonalSignApprovalRequest; + onResolve: (signature: EVMSignatureHex) => void; + onReject: (error: unknown) => void; +}) { + const { getSigner } = useWallet(); + const [phase, setPhase] = useState<"confirm" | "signing">("confirm"); + /** Bumped on cancel and each startSign so stale in-flight ops cannot settle. */ + const signGenerationRef = useRef(0); + + const cancel = () => { + signGenerationRef.current += 1; + onReject(new OwsUserRejectedError("User rejected the signing request")); + }; + + const startSign = () => { + const generation = ++signGenerationRef.current; + setPhase("signing"); + void (async () => { + const signer = getSigner(); + if (!signer) throw new Error("Signer not ready"); + const [signature] = await signer.evm.signMessage([request.message]); + if (signGenerationRef.current !== generation) return; + onResolve(signature!); + })().catch((error: unknown) => { + if (signGenerationRef.current !== generation) return; + if (isSignDenied(error)) { + setPhase("confirm"); + return; + } + onReject(error); + }); + }; + + return ( + +

Account

+

{request.address}

+

Message

+
+        {formatMessageForDisplay(request.message)}
+      
+ {phase === "signing" ? ( +

+ Confirm in the signing panel… +

+ ) : null} +
+ ); +} + +export function TypedDataModal({ + request, + onResolve, + onReject, +}: { + request: SignTypedDataApprovalRequest; + onResolve: (signature: EVMSignatureHex) => void; + onReject: (error: unknown) => void; +}) { + const { getSigner } = useWallet(); + const { typedData } = request; + const [phase, setPhase] = useState<"confirm" | "signing">("confirm"); + /** Bumped on cancel and each startSign so stale in-flight ops cannot settle. */ + const signGenerationRef = useRef(0); + + const cancel = () => { + signGenerationRef.current += 1; + onReject(new OwsUserRejectedError("User rejected the signing request")); + }; + + const startSign = () => { + const generation = ++signGenerationRef.current; + setPhase("signing"); + void (async () => { + const signer = getSigner(); + if (!signer) throw new Error("Signer not ready"); + const [signature] = await signer.evm.signTypedData([ + typedData as unknown as TypedDataDefinition, + ]); + if (signGenerationRef.current !== generation) return; + onResolve(signature!); + })().catch((error: unknown) => { + if (signGenerationRef.current !== generation) return; + if (isSignDenied(error)) { + setPhase("confirm"); + return; + } + onReject(error); + }); + }; + + return ( + +

Account

+

{request.address}

+ + + + {phase === "signing" ? ( +

+ Confirm in the signing panel… +

+ ) : null} +
+ ); +} + +export function SendTransactionModal({ + request, + onResolve, +}: { + request: SendTransactionApprovalRequest; + onResolve: (approved: boolean) => void; +}) { + return ( + onResolve(false)} + actions={[ + { + label: "Reject", + variant: "secondary", + onClick: () => onResolve(false), + }, + { + label: "Sign", + variant: "primary", + autoFocus: true, + onClick: () => onResolve(true), + }, + ]} + > +

Account

+

{request.address}

+ + + + +
+ ); +} + +function LabeledBlock({ label, content }: { label: string; content: string }) { + return ( +
+

{label}

+
+        {content}
+      
+
+ ); +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, (_key, v) => + typeof v === "bigint" ? v.toString() : v, + 2, + ); + } catch { + return String(value); + } +} + +function formatMessageForDisplay(message: string): string { + if (message.startsWith("0x") && message.length > 2) { + try { + const bytes = ConversionUtils.hexToBytes( + HexString(message as `0x${string}`), + ); + const decoded = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + if (isMostlyPrintable(decoded)) { + return decoded; + } + } catch { + // fall through + } + } + return message; +} + +function isMostlyPrintable(text: string): boolean { + if (!text.trim()) return false; + let printable = 0; + let codePoints = 0; + for (const char of text) { + codePoints++; + const code = char.codePointAt(0) ?? 0; + if (code >= 32 && code !== 127) printable++; + } + return codePoints > 0 && printable / codePoints >= 0.85; +} diff --git a/examples/general-wallet/src/index.css b/examples/general-wallet/src/index.css new file mode 100644 index 0000000..12aa1a2 --- /dev/null +++ b/examples/general-wallet/src/index.css @@ -0,0 +1,27 @@ +@import "tailwindcss"; + +:root { + color-scheme: light dark; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + font-family: system-ui, sans-serif; + line-height: 1.5; + background: Canvas; + color: CanvasText; +} + +#signer-container { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + clip-path: inset(50%); +} diff --git a/examples/general-wallet/src/main.tsx b/examples/general-wallet/src/main.tsx new file mode 100644 index 0000000..5ba93f0 --- /dev/null +++ b/examples/general-wallet/src/main.tsx @@ -0,0 +1,15 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import { WalletProvider } from "./wallet/WalletProvider"; +import "./index.css"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("#root not found"); +} + +createRoot(root).render( + + + , +); diff --git a/examples/general-wallet/src/ows/demoChains.ts b/examples/general-wallet/src/ows/demoChains.ts new file mode 100644 index 0000000..6ac6649 --- /dev/null +++ b/examples/general-wallet/src/ows/demoChains.ts @@ -0,0 +1,29 @@ +import { EVMChainId } from "@1shotapi/ows-types"; + +/** Demo chains for the branding-layer chain dropdown (fed into RpcHelper). */ +export const DEMO_CHAINS: ReadonlyArray<{ + chainId: EVMChainId; + label: string; + rpcUrl: string; + /** Base URL for tx/address explorer links (no trailing slash). */ + blockExplorerUrl: string; +}> = [ + { + chainId: EVMChainId("0xaa36a7"), // 11155111 + label: "Sepolia", + rpcUrl: "https://sepolia.drpc.org", + blockExplorerUrl: "https://sepolia.etherscan.io", + }, + { + chainId: EVMChainId("0x14a34"), // 84532 + label: "Base Sepolia", + rpcUrl: "https://sepolia.base.org", + blockExplorerUrl: "https://sepolia.basescan.org", + }, + { + chainId: EVMChainId("0x4cef52"), // 5042002 + label: "Arc Testnet", + rpcUrl: "https://rpc.testnet.arc.network", + blockExplorerUrl: "https://testnet.arcscan.app", + }, +]; diff --git a/examples/general-wallet/src/ows/registerAccountConnect.ts b/examples/general-wallet/src/ows/registerAccountConnect.ts new file mode 100644 index 0000000..321266c --- /dev/null +++ b/examples/general-wallet/src/ows/registerAccountConnect.ts @@ -0,0 +1,64 @@ +import type { OWSSigner } from "@1shotapi/ows-signer-utils"; +import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; +import { + EVMAccountAddress, + OwsUserRejectedError, + type SolanaAccountAddress, +} from "@1shotapi/ows-types"; + +export type AccountConnectStorage = { + loadCachedEvmAddress: () => EVMAccountAddress | undefined; + saveCachedAddresses: ( + evm: EVMAccountAddress, + solana?: SolanaAccountAddress, + ) => void; +}; + +export type RegisterAccountConnectOptions = { + storage: AccountConnectStorage; + ensureReady: () => Promise; + requestConnectApproval: () => Promise; +}; + +/** + * Register `eth_accounts` / `eth_requestAccounts` on the wallet (pre-`start()`). + */ +export function registerAccountConnect( + wallet: OWSWallet, + signer: OWSSigner, + options: RegisterAccountConnectOptions, +): void { + wallet.registerEip1193("eth_accounts", async () => { + const cached = options.storage.loadCachedEvmAddress(); + if (cached) { + return [cached]; + } + return []; + }); + + wallet.registerEip1193("eth_requestAccounts", async () => { + const cached = options.storage.loadCachedEvmAddress(); + if (cached) { + return [cached]; + } + + const display = await wallet.requestDisplay(); + try { + const approved = await options.requestConnectApproval(); + if (!approved) { + throw new OwsUserRejectedError( + "User rejected the account connection request", + ); + } + + await options.ensureReady(); + const evm = await signer.evm.getAccountAddress(); + const solana = await signer.solana.getAccountAddress(); + options.storage.saveCachedAddresses(evm, solana); + wallet.providerEvents.emit("accountsChanged", [evm]); + return [evm]; + } finally { + await display.hide(); + } + }); +} diff --git a/examples/general-wallet/src/ows/registerApprovalSigning.ts b/examples/general-wallet/src/ows/registerApprovalSigning.ts new file mode 100644 index 0000000..7656471 --- /dev/null +++ b/examples/general-wallet/src/ows/registerApprovalSigning.ts @@ -0,0 +1,128 @@ +import { + SignHelper, + prepareEvmTransaction, +} from "@1shotapi/ows-signer-utils"; +import type { + OWSSigner, + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignHelperChainRpc, + SignTypedDataApprovalRequest, +} from "@1shotapi/ows-signer-utils"; +import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; +import { + EVMTransactionHash, + OwsInvalidParamsError, + OwsUserRejectedError, + type EVMSignatureHex, + type EVMTransactionHash as EVMTransactionHashType, +} from "@1shotapi/ows-types"; + +export type RegisterApprovalSigningOptions = { + /** + * Setup-only gate before signed actions: run onboarding when no credential + * exists. With a known credential, skip unlock — the signing ceremony + * authenticates. Pair with {@link onAuthenticated}. + */ + ensureReady?: () => Promise; + /** Mark unlocked + refresh addresses after a successful signing ceremony. */ + onAuthenticated?: () => void | Promise; + chainRpc: SignHelperChainRpc; + /** + * Branding owns consent UI + `signMessage`. Keep the view open until the + * Signing Layer ceremony finishes. + */ + approveAndSignPersonalMessage: ( + request: PersonalSignApprovalRequest, + ) => Promise; + /** + * Branding owns consent UI + `signTypedData`. Keep the view open until the + * Signing Layer ceremony finishes. + */ + approveAndSignTypedData: ( + request: SignTypedDataApprovalRequest, + ) => Promise; + /** + * Full send path (consent + prepare + sign + broadcast). Defaults to + * {@link requestSendTransactionApproval} + prepare/sign/broadcast when + * omitted — prefer an implementation that keeps consent UI mounted during + * the ceremony. + */ + requestSendTransactionApproval?: ( + request: SendTransactionApprovalRequest, + ) => Promise; + approveAndSignTransaction?: ( + request: SendTransactionApprovalRequest, + ) => Promise; +}; + +/** + * Build SignHelper handlers and register them on the wallet (pre-`start()`). + * + * SignHelper only adapts EIP-1193 ↔ `approveAndSign*`. Setup / unlock live here + * so branding owns the link to `OWSSigner`. + */ +export function registerApprovalSigning( + wallet: OWSWallet, + signer: OWSSigner, + options: RegisterApprovalSigningOptions, +): SignHelper { + const approveAndSignTransaction = + options.approveAndSignTransaction ?? + (async (request: SendTransactionApprovalRequest) => { + const requestApproval = options.requestSendTransactionApproval; + if (!requestApproval) { + throw new OwsInvalidParamsError( + "approveAndSignTransaction or requestSendTransactionApproval is required", + ); + } + const approved = await requestApproval(request); + if (!approved) { + throw new OwsUserRejectedError("User rejected the transaction request"); + } + + const prepared = await prepareEvmTransaction( + options.chainRpc, + request.address, + request.transaction, + ); + const [signed] = await signer.evm.signTransaction([prepared]); + await options.onAuthenticated?.(); + + const hash = await options.chainRpc.request("eth_sendRawTransaction", [ + signed, + ]); + if (typeof hash !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(hash)) { + throw new OwsInvalidParamsError( + "eth_sendRawTransaction returned an invalid transaction hash", + ); + } + return EVMTransactionHash(hash as `0x${string}`); + }); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => options.chainRpc.getChainId(), + approveAndSignPersonalMessage: async (request) => { + await options.ensureReady?.(); + const signature = await options.approveAndSignPersonalMessage(request); + await options.onAuthenticated?.(); + return signature; + }, + approveAndSignTypedData: async (request) => { + await options.ensureReady?.(); + const signature = await options.approveAndSignTypedData(request); + await options.onAuthenticated?.(); + return signature; + }, + approveAndSignTransaction: async (request) => { + await options.ensureReady?.(); + return approveAndSignTransaction(request); + }, + }); + + for (const [method, handler] of Object.entries(helper.handlers)) { + wallet.registerEip1193(method, handler); + } + + return helper; +} diff --git a/examples/general-wallet/src/ows/registerCredentialsProvider.ts b/examples/general-wallet/src/ows/registerCredentialsProvider.ts new file mode 100644 index 0000000..27fe339 --- /dev/null +++ b/examples/general-wallet/src/ows/registerCredentialsProvider.ts @@ -0,0 +1,148 @@ +import type { OWSSigner } from "@1shotapi/ows-signer-utils"; +import type { OWSWallet } from "@1shotapi/ows-wallet-utils"; +import { + CredentialsHelper, + createCredentialsHolderSigner, + issueCredentialAfterApproval, + presentCredentialAfterApproval, + type ApproveAndAcceptOfferRequest, + type ApproveAndPresentRequest, +} from "@1shotapi/ows-oid4"; +import { + OwsUserRejectedError, + type CredentialOfferApprovalRequest, + type CredentialPresentationApprovalRequest, + type ICredentialRepository, + type ICredentialStatusValidator, + type IHolderSigner, + type IIssuerTrustRegistry, + type IOid4vciClient, + type IOid4vpClient, + type IWalletAttestationProvider, + type IssuerMetadata, +} from "@1shotapi/ows-types"; +import { isWalletCreated } from "../storage"; + +export type RegisterCredentialsProviderOptions = { + repository: ICredentialRepository; + oid4vci: IOid4vciClient; + oid4vp: IOid4vpClient; + trust: IIssuerTrustRegistry; + status?: ICredentialStatusValidator; + holderSigner?: IHolderSigner | (() => Promise); + getProofNonce?: (metadata: IssuerMetadata) => string | Promise; + attestationProvider?: IWalletAttestationProvider; + /** + * Full unlock/setup — used when the local vault is empty (recover) or for + * credential delete. Prefer setup-only {@link ensureOnboarded} for issue PoP. + */ + ensureReady?: () => Promise; + /** + * Setup-only when no credential exists. With a known passkey, skip unlock — + * the PoP ceremony authenticates. Pair with {@link onAuthenticated}. + */ + ensureOnboarded?: () => Promise; + /** Mark unlocked after a successful PoP / issue ceremony. */ + onAuthenticated?: () => void | Promise; + requestCredentialOfferApproval?: ( + request: CredentialOfferApprovalRequest, + ) => Promise; + requestCredentialPresentationApproval?: ( + request: CredentialPresentationApprovalRequest, + ) => Promise; +}; + +/** + * Register `wallet.credentials` via {@link CredentialsHelper} (pre-`start()`). + * + * Helper only resolves/match/trust + display. Branding `approveAnd*` owns consent, + * setup, and PoP (SignHelper parity). + */ +export function registerCredentialsProvider( + wallet: OWSWallet, + signer: OWSSigner, + options: RegisterCredentialsProviderOptions, +): CredentialsHelper { + const resolveHolderSigner = createCredentialsHolderSigner( + signer, + options.holderSigner, + ); + + const approveAndAcceptOffer = async ( + request: ApproveAndAcceptOfferRequest, + ) => { + await (options.ensureOnboarded ?? options.ensureReady)?.(); + if (options.requestCredentialOfferApproval) { + const approved = await options.requestCredentialOfferApproval(request); + if (!approved) { + throw new OwsUserRejectedError("User rejected credential offer"); + } + } + const receipt = await issueCredentialAfterApproval({ + offer: request.offer, + metadata: request.metadata, + oid4vci: options.oid4vci, + repository: options.repository, + resolveHolderSigner, + getProofNonce: options.getProofNonce, + attestationProvider: options.attestationProvider, + status: options.status, + }); + await options.onAuthenticated?.(); + return receipt; + }; + + const approveAndPresent = async (request: ApproveAndPresentRequest) => { + if (options.requestCredentialPresentationApproval) { + const approved = + await options.requestCredentialPresentationApproval(request); + if (!approved) { + throw new OwsUserRejectedError("User rejected credential presentation"); + } + } + const result = await presentCredentialAfterApproval({ + definition: request.definition, + credential: request.credential, + oid4vp: options.oid4vp, + resolveHolderSigner, + attestationProvider: options.attestationProvider, + }); + await options.onAuthenticated?.(); + return result; + }; + + const helper = new CredentialsHelper(wallet, { + repository: options.repository, + oid4vci: options.oid4vci, + oid4vp: options.oid4vp, + trust: options.trust, + status: options.status, + approveAndAcceptOffer, + approveAndPresent, + }); + + const ensureReady = options.ensureReady ?? (async () => {}); + + wallet.credentials.register({ + acceptOffer: (input) => helper.handlers.acceptOffer(input), + present: async (input) => { + // Empty local vault (new top-level origin) — unlock/recover before match. + if (!isWalletCreated()) { + await ensureReady(); + } else { + const listed = await options.repository.list(); + if (listed.length === 0) { + await ensureReady(); + } + } + return helper.handlers.present(input); + }, + list: (filter) => helper.handlers.list(filter), + delete: async (input) => { + await ensureReady(); + return helper.handlers.delete(input); + }, + }); + + return helper; +} diff --git a/examples/general-wallet/src/storage.ts b/examples/general-wallet/src/storage.ts new file mode 100644 index 0000000..080b66c --- /dev/null +++ b/examples/general-wallet/src/storage.ts @@ -0,0 +1,81 @@ +import { + CredentialIdSchema, + EVMAccountAddress, + SolanaAccountAddress, + type CredentialId, +} from "@1shotapi/ows-types"; +import { getAddress, isAddress } from "viem"; + +const WALLET_CREATED_KEY = "ows-wallet-created"; +/** WebAuthn credential id (public selector for allowCredentials) — not a bearer secret. */ +const CREDENTIAL_ID_KEY = "ows-credential-id"; +const BACKUP_KEY = "ows-wallet-backup"; +const EVM_ADDRESS_KEY = "ows-evm-address"; +const SOLANA_ADDRESS_KEY = "ows-solana-address"; + +export function isWalletCreated(): boolean { + return localStorage.getItem(WALLET_CREATED_KEY) === "true"; +} + +export function loadCredentialId(): CredentialId | undefined { + const storedCredentialId = localStorage.getItem(CREDENTIAL_ID_KEY); + if (storedCredentialId == null) { + return undefined; + } + const parsed = CredentialIdSchema.safeParse(storedCredentialId); + return parsed.success ? parsed.data : undefined; +} + +export function saveWalletCreated(credentialId: string): void { + localStorage.setItem(WALLET_CREATED_KEY, "true"); + // WebAuthn credential id is a public allowCredentials selector — not a bearer token. + // react-doctor-disable-next-line react-doctor/auth-token-in-web-storage + localStorage.setItem(CREDENTIAL_ID_KEY, credentialId); +} + +export function loadCachedEvmAddress(): EVMAccountAddress | undefined { + const value = localStorage.getItem(EVM_ADDRESS_KEY); + if (!value || !isAddress(value)) { + return undefined; + } + return EVMAccountAddress(getAddress(value)); +} + +export function loadCachedSolanaAddress(): SolanaAccountAddress | undefined { + const value = localStorage.getItem(SOLANA_ADDRESS_KEY); + if (!value) { + return undefined; + } + return SolanaAccountAddress(value); +} + +export function saveCachedAddresses( + evm: EVMAccountAddress, + solana?: SolanaAccountAddress, +): void { + localStorage.setItem(EVM_ADDRESS_KEY, evm); + if (solana) { + localStorage.setItem(SOLANA_ADDRESS_KEY, solana); + } +} + +export function clearWalletStorage(): void { + localStorage.removeItem(WALLET_CREATED_KEY); + localStorage.removeItem(CREDENTIAL_ID_KEY); + localStorage.removeItem(BACKUP_KEY); + localStorage.removeItem(EVM_ADDRESS_KEY); + localStorage.removeItem(SOLANA_ADDRESS_KEY); +} + +/** App-owned backup blob (`ows1:…`). */ +export function saveBackup(encryptedPrivateKey: string): void { + localStorage.setItem(BACKUP_KEY, encryptedPrivateKey); +} + +export function loadBackup(): string | undefined { + return localStorage.getItem(BACKUP_KEY) ?? undefined; +} + +export function hasBackup(): boolean { + return Boolean(loadBackup()); +} diff --git a/examples/general-wallet/src/wallet/WalletProvider.tsx b/examples/general-wallet/src/wallet/WalletProvider.tsx new file mode 100644 index 0000000..76e231c --- /dev/null +++ b/examples/general-wallet/src/wallet/WalletProvider.tsx @@ -0,0 +1,177 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject, +} from "react"; +import { OWSSigner } from "@1shotapi/ows-signer-utils"; +import { OWSWallet, type RpcHelper } from "@1shotapi/ows-wallet-utils"; +import { + EVMAccountAddress, + EVMChainId, + SolanaAccountAddress, +} from "@1shotapi/ows-types"; +import { LocalStorageCredentialRepository } from "@ows-shared"; +import { DEMO_CHAINS } from "../ows/demoChains"; +import { + isWalletCreated, + loadCachedEvmAddress, + loadCachedSolanaAddress, +} from "../storage"; +import type { ActiveModal } from "./modalTypes"; +import { useModalQueue } from "./useModalQueue"; +import { useWalletBoot } from "./useWalletBoot"; +import { useWalletSession } from "./useWalletSession"; + +export type WalletContextValue = { + ready: boolean; + bootError: string | null; + embedded: boolean; + unlocked: boolean; + walletCreated: boolean; + evmAddress: EVMAccountAddress; + solanaAddress: SolanaAccountAddress; + chainId: EVMChainId; + chains: typeof DEMO_CHAINS; + credentialCount: number; + activeModal: ActiveModal | null; + signerContainerRef: RefObject; + getSigner: () => OWSSigner | null; + /** Resolves when the Signing Layer iframe has finished loading. */ + awaitSignerReady: () => Promise; + /** Awaits Signing Layer load, then unlocks / runs setup if needed. */ + ensureReady: () => Promise; + setUnlocked: (value: boolean) => void; + refreshAddresses: () => Promise; + refreshCredentialCount: () => Promise; + switchChain: (chainId: string) => Promise; + requestHide: () => Promise; + openCredentialList: () => Promise; + openCreateBackup: () => Promise; + openRestoreBackup: () => Promise; + loginWithPasskey: () => Promise; + createNewWalletFromUi: () => Promise; + persistBackup: (encryptedPrivateKey: string) => void; +}; + +const WalletContext = createContext(null); + +export function useWallet(): WalletContextValue { + const value = useContext(WalletContext); + if (!value) { + throw new Error("useWallet must be used within WalletProvider"); + } + return value; +} + +export function WalletProvider({ children }: { children: ReactNode }) { + const [walletCreated, setWalletCreated] = useState(() => isWalletCreated()); + const [evmAddress, setEvmAddress] = useState( + () => loadCachedEvmAddress() ?? EVMAccountAddress("0x0"), + ); + const [solanaAddress, setSolanaAddress] = useState( + () => loadCachedSolanaAddress() ?? SolanaAccountAddress("—"), + ); + const [chainId, setChainId] = useState(DEMO_CHAINS[0]!.chainId); + const [credentialCount, setCredentialCount] = useState(0); + const [embedded] = useState(() => window.parent !== window); + + const signerContainerRef = useRef(null); + const walletRef = useRef(null); + const signerRef = useRef(null); + const rpcHelperRef = useRef(null); + const awaitSignerRef = useRef<(() => Promise) | null>(null); + // Single owner for OID4 credential storage — passed into boot + session. + const credentialRepositoryRef = useRef( + null, + ); + if (credentialRepositoryRef.current === null) { + credentialRepositoryRef.current = new LocalStorageCredentialRepository(); + } + const credentialRepository = credentialRepositoryRef.current; + + const { activeModal, pushModal } = useModalQueue(); + + const session = useWalletSession({ + signerRef, + walletRef, + rpcHelperRef, + awaitSignerRef, + pushModal, + credentialRepository, + setEvmAddress, + setSolanaAddress, + setCredentialCount, + setWalletCreated, + setChainId, + }); + + const { ready, bootError } = useWalletBoot({ + signerContainerRef, + walletRef, + signerRef, + rpcHelperRef, + awaitSignerRef, + pushModal, + credentialRepository, + ensureReady: session.ensureReady, + ensureOnboardedForSigning: session.ensureOnboardedForSigning, + onSigningAuthenticated: session.onSigningAuthenticated, + setChainId, + setCredentialCount, + }); + + const getSigner = useCallback(() => signerRef.current, []); + + const value = useMemo( + () => ({ + ready, + bootError, + embedded, + unlocked: session.unlocked, + walletCreated, + evmAddress, + solanaAddress, + chainId, + chains: DEMO_CHAINS, + credentialCount, + activeModal, + signerContainerRef, + getSigner, + awaitSignerReady: session.awaitSignerReady, + ensureReady: session.ensureReady, + setUnlocked: session.setUnlocked, + refreshAddresses: session.refreshAddresses, + refreshCredentialCount: session.refreshCredentialCount, + switchChain: session.switchChain, + requestHide: session.requestHide, + openCredentialList: session.openCredentialList, + openCreateBackup: session.openCreateBackup, + openRestoreBackup: session.openRestoreBackup, + loginWithPasskey: session.loginWithPasskey, + createNewWalletFromUi: session.createNewWalletFromUi, + persistBackup: session.persistBackup, + }), + [ + ready, + bootError, + embedded, + session, + walletCreated, + evmAddress, + solanaAddress, + chainId, + credentialCount, + activeModal, + getSigner, + ], + ); + + return ( + {children} + ); +} diff --git a/examples/general-wallet/src/wallet/deferredSigner.ts b/examples/general-wallet/src/wallet/deferredSigner.ts new file mode 100644 index 0000000..15e809e --- /dev/null +++ b/examples/general-wallet/src/wallet/deferredSigner.ts @@ -0,0 +1,49 @@ +import { OWSSigner } from "@1shotapi/ows-signer-utils"; + +/** + * Proxy that forwards to a real {@link OWSSigner} once `awaitSigner` resolves. + * Register Postmate handlers with this so `wallet.start()` can run before the + * nested Signing Layer iframe finishes loading (Postmate parents give up after + * ~2.5s of handshake retries). + */ +export function createDeferredSigner( + awaitSigner: () => Promise, +): OWSSigner { + let instance: OWSSigner | undefined; + let loadError: unknown; + void awaitSigner() + .then((signer) => { + instance = signer; + }) + .catch((error: unknown) => { + loadError = error; + console.error( + "[ows-example-general-wallet] deferred Signing Layer load failed", + error, + ); + }); + return new Proxy({} as OWSSigner, { + get(_target, property) { + // Avoid looking like a thenable if someone awaits the proxy. + if (property === "then") { + return undefined; + } + if (!instance) { + if (loadError !== undefined) { + throw loadError instanceof Error + ? loadError + : new Error( + `Signing Layer failed to load: ${String(loadError)}`, + ); + } + throw new Error( + "Signing Layer not ready — await ensureReady() before using the signer", + ); + } + const value = Reflect.get(instance, property, instance); + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(instance) + : value; + }, + }); +} diff --git a/examples/general-wallet/src/wallet/modalTypes.ts b/examples/general-wallet/src/wallet/modalTypes.ts new file mode 100644 index 0000000..004ff6d --- /dev/null +++ b/examples/general-wallet/src/wallet/modalTypes.ts @@ -0,0 +1,92 @@ +import type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, +} from "@1shotapi/ows-signer-utils"; +import type { + CredentialOfferApprovalRequest, + CredentialPresentationApprovalRequest, + CredentialSummary, + EVMSignatureHex, + RecoveryDataCreatedData, +} from "@1shotapi/ows-types"; + +export type WalletSetupChoice = "login" | "create" | "cancel"; + +export type ModalRequest = + | { + id: string; + kind: "walletSetup"; + resolve: (choice: WalletSetupChoice) => void; + } + | { + id: string; + kind: "passkeyName"; + resolve: (name: string | null) => void; + } + | { + id: string; + kind: "connect"; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "personalSign"; + request: PersonalSignApprovalRequest; + resolve: (signature: EVMSignatureHex) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "typedData"; + request: SignTypedDataApprovalRequest; + resolve: (signature: EVMSignatureHex) => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "sendTransaction"; + request: SendTransactionApprovalRequest; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "credentialOffer"; + request: CredentialOfferApprovalRequest; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "credentialPresentation"; + request: CredentialPresentationApprovalRequest; + resolve: (approved: boolean) => void; + } + | { + id: string; + kind: "credentialList"; + credentials: CredentialSummary[]; + resolve: () => void; + } + | { + id: string; + kind: "createBackup"; + resolve: () => void; + reject: (error: unknown) => void; + } + | { + id: string; + kind: "restoreBackup"; + encryptedPrivateKey: string; + resolve: (restored: boolean) => void; + }; + +export type ActiveModal = ModalRequest; + +let modalId = 0; + +export function nextModalId(): string { + modalId += 1; + return `modal-${modalId}`; +} + +export type CreateBackupResult = RecoveryDataCreatedData; diff --git a/examples/general-wallet/src/wallet/useModalQueue.ts b/examples/general-wallet/src/wallet/useModalQueue.ts new file mode 100644 index 0000000..e6b08d9 --- /dev/null +++ b/examples/general-wallet/src/wallet/useModalQueue.ts @@ -0,0 +1,57 @@ +import { useCallback, useRef, useState } from "react"; +import { nextModalId, type ActiveModal } from "./modalTypes"; + +export function useModalQueue() { + const [activeModal, setActiveModal] = useState(null); + const modalQueueRef = useRef([]); + + const advanceQueue = useCallback(() => { + const next = modalQueueRef.current[0] ?? null; + setActiveModal(next); + }, []); + + const removeModal = useCallback( + (id: string) => { + modalQueueRef.current = modalQueueRef.current.filter((m) => m.id !== id); + advanceQueue(); + }, + [advanceQueue], + ); + + const pushModal = useCallback( + ( + build: (handlers: { + id: string; + resolve: (value: T) => void; + reject: (error: unknown) => void; + }) => ActiveModal, + ): Promise => { + return new Promise((resolve, reject) => { + const id = nextModalId(); + let settled = false; + const finishResolve = (value: T) => { + if (settled) return; + settled = true; + removeModal(id); + resolve(value); + }; + const finishReject = (error: unknown) => { + if (settled) return; + settled = true; + removeModal(id); + reject(error); + }; + const modal = build({ + id, + resolve: finishResolve, + reject: finishReject, + }); + modalQueueRef.current.push(modal); + setActiveModal((current) => current ?? modal); + }); + }, + [removeModal], + ); + + return { activeModal, pushModal }; +} diff --git a/examples/general-wallet/src/wallet/useWalletBoot.ts b/examples/general-wallet/src/wallet/useWalletBoot.ts new file mode 100644 index 0000000..d9744b1 --- /dev/null +++ b/examples/general-wallet/src/wallet/useWalletBoot.ts @@ -0,0 +1,313 @@ +import { + useEffect, + useEffectEvent, + useState, + type RefObject, +} from "react"; +import { OWSSigner } from "@1shotapi/ows-signer-utils"; +import { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; +import { + EVMChainId, + type CredentialOfferApprovalRequest, + type CredentialPresentationApprovalRequest, + type EVMSignatureHex, +} from "@1shotapi/ows-types"; +import type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, +} from "@1shotapi/ows-signer-utils"; +import { + LocalStorageCredentialRepository, + InMemoryIssuerTrustRegistry, + DEMO_HOLDER_PRIVATE_JWK, +} from "@ows-shared"; +import { + DemoWalletAttestationProvider, + FetchUtils, + HttpOid4vciClient, + HttpOid4vpClient, + ParseUtils, +} from "@1shotapi/ows-oid4"; +import { PersonalSignEvent } from "../analytics/events"; +import { resolveHostDomain } from "../analytics/hostDomain"; +import { DEMO_CHAINS } from "../ows/demoChains"; +import { registerAccountConnect } from "../ows/registerAccountConnect"; +import { registerApprovalSigning } from "../ows/registerApprovalSigning"; +import { registerCredentialsProvider } from "../ows/registerCredentialsProvider"; +import { + isWalletCreated, + loadCachedEvmAddress, + loadCredentialId, + saveCachedAddresses, + saveWalletCreated, +} from "../storage"; +import { createDeferredSigner } from "./deferredSigner"; +import type { ActiveModal } from "./modalTypes"; + +const issuerTrust = new InMemoryIssuerTrustRegistry(); +const fetchUtils = new FetchUtils(); +const parseUtils = new ParseUtils(); +const oid4vci = new HttpOid4vciClient(fetchUtils, parseUtils); +const oid4vp = new HttpOid4vpClient(fetchUtils); +const attestationProvider = new DemoWalletAttestationProvider({ + privateJwk: DEMO_HOLDER_PRIVATE_JWK, + issuer: "ows-demo-wallet", +}); + +const walletStorage = { + isWalletCreated, + loadCredentialId, + saveWalletCreated, + saveCachedAddresses, + loadCachedEvmAddress, +}; + +type PushModal = ( + build: (handlers: { + id: string; + resolve: (value: T) => void; + reject: (error: unknown) => void; + }) => ActiveModal, +) => Promise; + +type UseWalletBootArgs = { + signerContainerRef: RefObject; + walletRef: RefObject; + signerRef: RefObject; + rpcHelperRef: RefObject; + awaitSignerRef: RefObject<(() => Promise) | null>; + pushModal: PushModal; + credentialRepository: LocalStorageCredentialRepository; + ensureReady: () => Promise; + ensureOnboardedForSigning: () => Promise; + onSigningAuthenticated: () => Promise; + setChainId: (value: EVMChainId) => void; + setCredentialCount: (value: number) => void; +}; + +export function useWalletBoot({ + signerContainerRef, + walletRef, + signerRef, + rpcHelperRef, + awaitSignerRef, + pushModal, + credentialRepository, + ensureReady, + ensureOnboardedForSigning, + onSigningAuthenticated, + setChainId, + setCredentialCount, +}: UseWalletBootArgs): { ready: boolean; bootError: string | null } { + const [ready, setReady] = useState(false); + const [bootError, setBootError] = useState(null); + + // Always call the latest session callbacks from mount-only boot registrations. + const askModal = useEffectEvent(pushModal); + const runEnsureReady = useEffectEvent(ensureReady); + const runEnsureOnboarded = useEffectEvent(ensureOnboardedForSigning); + const runOnAuthenticated = useEffectEvent(onSigningAuthenticated); + const syncChainId = useEffectEvent(setChainId); + const syncCredentialCount = useEffectEvent(setCredentialCount); + + useEffect(() => { + let cancelled = false; + + async function boot(): Promise { + // Wait a tick so SignerHost has committed the ref. + await Promise.resolve(); + const container = signerContainerRef.current; + if (!container) { + throw new Error("#signer-container not mounted"); + } + + // Kick off Signing Layer load without blocking the host Postmate handshake. + // Postmate parents only retry ~5 times (~2.5s after iframe load); awaiting + // the nested /signer/ iframe (especially over ngrok) exceeds that window. + const signerUrl = new URL("/signer/", window.location.origin).href; + const signerPromise = OWSSigner.create(container, signerUrl, { + hidden: true, + credentialId: loadCredentialId(), + }); + const awaitSigner = async (): Promise => { + const loaded = await signerPromise; + signerRef.current = loaded; + return loaded; + }; + awaitSignerRef.current = awaitSigner; + // Handlers close over this proxy; they must call ensureReady (awaits signer) + // before touching signing APIs. + const signer = createDeferredSigner(awaitSigner); + + const wallet = OWSWallet.prepare({ debug: true }); + walletRef.current = wallet; + + const ask = ( + build: (handlers: { + id: string; + resolve: (value: T) => void; + reject: (error: unknown) => void; + }) => ActiveModal, + ) => askModal(build); + + registerAccountConnect(wallet, signer, { + storage: walletStorage, + ensureReady: () => runEnsureReady(), + requestConnectApproval: () => + ask(({ id, resolve }) => ({ + id, + kind: "connect", + resolve, + })), + }); + + const defaultChainId = DEMO_CHAINS[0]!.chainId; + const rpcHelper = new RpcHelper( + new Map(DEMO_CHAINS.map((chain) => [chain.chainId, chain.rpcUrl])), + wallet, + signer, + { defaultChainId }, + ); + rpcHelperRef.current = rpcHelper; + syncChainId(rpcHelper.getChainId()); + + registerApprovalSigning(wallet, signer, { + ensureReady: () => runEnsureOnboarded(), + onAuthenticated: () => runOnAuthenticated(), + chainRpc: rpcHelper, + approveAndSignPersonalMessage: async ( + request: PersonalSignApprovalRequest, + ) => { + const started = performance.now(); + const signature = await ask( + ({ id, resolve, reject }) => ({ + id, + kind: "personalSign", + request, + resolve, + reject, + }), + ); + wallet.analytics.emit( + new PersonalSignEvent( + resolveHostDomain(), + request.address, + request.message.length, + Math.round(performance.now() - started), + ), + ); + return signature; + }, + approveAndSignTypedData: (request: SignTypedDataApprovalRequest) => + ask(({ id, resolve, reject }) => ({ + id, + kind: "typedData", + request, + resolve, + reject, + })), + requestSendTransactionApproval: ( + request: SendTransactionApprovalRequest, + ) => + ask(({ id, resolve }) => ({ + id, + kind: "sendTransaction", + request, + resolve, + })), + }); + + registerCredentialsProvider(wallet, signer, { + repository: credentialRepository, + oid4vci, + oid4vp, + trust: issuerTrust, + attestationProvider, + ensureReady: () => runEnsureReady(), + ensureOnboarded: () => runEnsureOnboarded(), + onAuthenticated: () => runOnAuthenticated(), + requestCredentialOfferApproval: ( + request: CredentialOfferApprovalRequest, + ) => + askModal(({ id, resolve }) => ({ + id, + kind: "credentialOffer", + request, + resolve, + })), + requestCredentialPresentationApproval: ( + request: CredentialPresentationApprovalRequest, + ) => + askModal(({ id, resolve }) => ({ + id, + kind: "credentialPresentation", + request, + resolve, + })), + }); + + // Register Postmate.Model immediately — before nested signer iframe load. + void wallet.start().catch((error: unknown) => { + if (cancelled) return; + console.error( + "[ows-example-general-wallet] Postmate handshake failed", + error, + ); + setBootError(error instanceof Error ? error.message : String(error)); + }); + + // Finish Signing Layer init in the background; UI can paint meanwhile. + void awaitSigner().catch((error: unknown) => { + if (cancelled) return; + console.error( + "[ows-example-general-wallet] Signing Layer failed to load", + error, + ); + setBootError(error instanceof Error ? error.message : String(error)); + }); + + // Paint UI without awaiting host handshake — standalone /wallet/ has no parent. + const listed = await credentialRepository.list(); + if (cancelled) return; + syncCredentialCount(listed.length); + setReady(true); + console.info("[ows-example-general-wallet] ready", { + chainId: rpcHelper.getChainId(), + }); + } + + void boot().catch((error: unknown) => { + console.error("[ows-example-general-wallet] failed to start", error); + setBootError(error instanceof Error ? error.message : String(error)); + }); + + return () => { + cancelled = true; + }; + }, [ + awaitSignerRef, + credentialRepository, + rpcHelperRef, + signerContainerRef, + signerRef, + walletRef, + ]); + + // Subscribe after boot sets rpcHelper — top-level effect so cleanup is obvious. + useEffect(() => { + if (!ready) return; + const rpc = rpcHelperRef.current; + if (!rpc) return; + const handleChainChanged = (next: EVMChainId) => { + syncChainId(next); + walletRef.current?.providerEvents.emit("chainChanged", next); + }; + rpc.events.on("chainChanged", handleChainChanged); + return () => { + rpc.events.off("chainChanged", handleChainChanged); + }; + }, [ready, rpcHelperRef, walletRef]); + + return { ready, bootError }; +} diff --git a/examples/general-wallet/src/wallet/useWalletSession.ts b/examples/general-wallet/src/wallet/useWalletSession.ts new file mode 100644 index 0000000..db79ae9 --- /dev/null +++ b/examples/general-wallet/src/wallet/useWalletSession.ts @@ -0,0 +1,363 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react"; +import { OWSSigner } from "@1shotapi/ows-signer-utils"; +import { OWSWallet, type RpcHelper } from "@1shotapi/ows-wallet-utils"; +import { + EVMAccountAddress, + EVMChainId, + OwsUserRejectedError, + SolanaAccountAddress, +} from "@1shotapi/ows-types"; +import type { LocalStorageCredentialRepository } from "@ows-shared"; +import { AccountCreatedEvent } from "../analytics/events"; +import { resolveHostDomain } from "../analytics/hostDomain"; +import { + isWalletCreated, + loadBackup, + loadCredentialId, + saveBackup, + saveCachedAddresses, + saveWalletCreated, +} from "../storage"; +import type { ActiveModal, WalletSetupChoice } from "./modalTypes"; + +type PushModal = ( + build: (handlers: { + id: string; + resolve: (value: T) => void; + reject: (error: unknown) => void; + }) => ActiveModal, +) => Promise; + +type UseWalletSessionArgs = { + signerRef: RefObject; + walletRef: RefObject; + rpcHelperRef: RefObject; + awaitSignerRef: RefObject<(() => Promise) | null>; + pushModal: PushModal; + credentialRepository: LocalStorageCredentialRepository; + setEvmAddress: (value: EVMAccountAddress) => void; + setSolanaAddress: (value: SolanaAccountAddress) => void; + setCredentialCount: (value: number) => void; + setWalletCreated: (value: boolean) => void; + setChainId: (value: EVMChainId) => void; +}; + +export function useWalletSession({ + signerRef, + walletRef, + rpcHelperRef, + awaitSignerRef, + pushModal, + credentialRepository, + setEvmAddress, + setSolanaAddress, + setCredentialCount, + setWalletCreated, + setChainId, +}: UseWalletSessionArgs) { + const unlockedRef = useRef(false); + const unlockInFlightRef = useRef | undefined>(undefined); + const [unlocked, setUnlockedState] = useState(false); + + const setUnlocked = useCallback((value: boolean) => { + unlockedRef.current = value; + setUnlockedState(value); + }, []); + + const refreshAddresses = useCallback(async () => { + const signer = signerRef.current; + if (!signer) return; + const [evm, solana] = await Promise.all([ + signer.evm.getAccountAddress(), + signer.solana.getAccountAddress(), + ]); + setEvmAddress(evm); + setSolanaAddress(solana); + saveCachedAddresses(evm, solana); + }, [setEvmAddress, setSolanaAddress, signerRef]); + + const refreshCredentialCount = useCallback(async () => { + const listed = await credentialRepository.list(); + setCredentialCount(listed.length); + }, [credentialRepository, setCredentialCount]); + + const awaitSignerReady = useCallback(async (): Promise => { + const awaitSigner = awaitSignerRef.current; + if (!awaitSigner) { + throw new Error( + "Signing Layer not started — wallet boot has not begun yet", + ); + } + return awaitSigner(); + }, [awaitSignerRef]); + + const promptPasskeyName = useCallback((): Promise => { + return pushModal(({ id, resolve }) => ({ + id, + kind: "passkeyName", + resolve, + })); + }, [pushModal]); + + const requestWalletSetupChoice = + useCallback((): Promise => { + return pushModal(({ id, resolve }) => ({ + id, + kind: "walletSetup", + resolve, + })); + }, [pushModal]); + + const loginWithPasskey = useCallback(async () => { + const signer = await awaitSignerReady(); + const result = await signer.getPublicKey({ discoverable: true }); + const credentialId = result.credentialId ?? signer.getCredentialId(); + if (!credentialId) { + throw new Error("Passkey login succeeded but credential id missing"); + } + saveWalletCreated(credentialId); + setWalletCreated(true); + await refreshAddresses(); + setUnlocked(true); + }, [awaitSignerReady, refreshAddresses, setUnlocked, setWalletCreated]); + + const createNewWallet = useCallback( + async (accountName: string) => { + const signer = await awaitSignerReady(); + await signer.createCredential(accountName, { + rpName: "Open Wallet", + userDisplayName: accountName, + }); + const credentialId = signer.getCredentialId(); + if (!credentialId) { + throw new Error("Passkey created but credential id missing"); + } + saveWalletCreated(credentialId); + setWalletCreated(true); + await refreshAddresses(); + setUnlocked(true); + + const accountAddress = await signer.evm.getAccountAddress(); + walletRef.current?.analytics.emit( + new AccountCreatedEvent(resolveHostDomain(), accountAddress), + ); + }, + [ + awaitSignerReady, + refreshAddresses, + setUnlocked, + setWalletCreated, + walletRef, + ], + ); + + const createNewWalletFromUi = useCallback(async () => { + const name = await promptPasskeyName(); + if (!name) { + throw new OwsUserRejectedError("User cancelled passkey creation"); + } + await createNewWallet(name); + }, [createNewWallet, promptPasskeyName]); + + const unlockWithStoredCredential = useCallback(async () => { + const signer = await awaitSignerReady(); + const storedCredentialId = loadCredentialId(); + if (storedCredentialId) { + const result = await signer.getPublicKey({ + credentialId: storedCredentialId, + }); + const credentialId = result.credentialId ?? signer.getCredentialId(); + if (!credentialId) { + throw new Error("Passkey unlock succeeded but credential id missing"); + } + saveWalletCreated(credentialId); + setWalletCreated(true); + await refreshAddresses(); + setUnlocked(true); + return; + } + await loginWithPasskey(); + }, [ + awaitSignerReady, + loginWithPasskey, + refreshAddresses, + setUnlocked, + setWalletCreated, + ]); + + const runSetupFlow = useCallback(async () => { + const wallet = walletRef.current; + if (!wallet) throw new Error("Wallet not ready"); + const display = await wallet.requestDisplay(); + try { + const choice = await requestWalletSetupChoice(); + if (choice === "cancel") { + throw new OwsUserRejectedError("User cancelled wallet setup"); + } + if (choice === "login") { + await loginWithPasskey(); + return; + } + await createNewWalletFromUi(); + } finally { + await display.hide(); + } + }, [ + createNewWalletFromUi, + loginWithPasskey, + requestWalletSetupChoice, + walletRef, + ]); + + const ensureReadyImpl = useCallback(async () => { + if (unlockedRef.current) { + return; + } + if (unlockInFlightRef.current) { + await unlockInFlightRef.current; + return; + } + + unlockInFlightRef.current = (async () => { + if (isWalletCreated()) { + await unlockWithStoredCredential(); + return; + } + await runSetupFlow(); + })(); + + try { + await unlockInFlightRef.current; + } finally { + unlockInFlightRef.current = undefined; + } + }, [runSetupFlow, unlockWithStoredCredential]); + + const ensureReadyRef = useRef(ensureReadyImpl); + useEffect(() => { + ensureReadyRef.current = ensureReadyImpl; + }, [ensureReadyImpl]); + + const ensureReady = useCallback(async () => { + await awaitSignerReady(); + await ensureReadyRef.current(); + }, [awaitSignerReady]); + + const ensureOnboardedForSigning = useCallback(async () => { + await awaitSignerReady(); + if (isWalletCreated()) { + return; + } + await ensureReadyRef.current(); + }, [awaitSignerReady]); + + const onSigningAuthenticated = useCallback(async () => { + await refreshAddresses(); + setUnlocked(true); + }, [refreshAddresses, setUnlocked]); + + const switchChain = useCallback( + async (next: string) => { + const rpc = rpcHelperRef.current; + if (!rpc) return; + const previous = rpc.getChainId(); + try { + await rpc.switchChain(next); + } catch (error: unknown) { + setChainId(previous); + console.error( + "[ows-example-general-wallet] chain switch failed", + error, + ); + throw error; + } + }, + [rpcHelperRef, setChainId], + ); + + const requestHide = useCallback(async () => { + await walletRef.current?.requestHide(); + }, [walletRef]); + + const openCredentialList = useCallback(async () => { + const listed = await credentialRepository.list(); + setCredentialCount(listed.length); + await pushModal(({ id, resolve }) => ({ + id, + kind: "credentialList", + credentials: listed, + resolve, + })); + }, [credentialRepository, pushModal, setCredentialCount]); + + const openCreateBackup = useCallback(async () => { + const wallet = walletRef.current; + if (!wallet) return; + const display = await wallet.requestDisplay(); + try { + await pushModal(({ id, resolve, reject }) => ({ + id, + kind: "createBackup", + resolve, + reject, + })); + } finally { + await display.hide(); + } + }, [pushModal, walletRef]); + + const openRestoreBackup = useCallback(async () => { + const encrypted = loadBackup(); + if (!encrypted) { + window.alert("No backup found. Create a backup first."); + return; + } + const wallet = walletRef.current; + if (!wallet) return; + const display = await wallet.requestDisplay(); + try { + const restored = await pushModal(({ id, resolve }) => ({ + id, + kind: "restoreBackup", + encryptedPrivateKey: encrypted, + resolve, + })); + if (restored) { + setUnlocked(true); + setWalletCreated(true); + await refreshAddresses(); + } + } finally { + await display.hide(); + } + }, [pushModal, refreshAddresses, setUnlocked, setWalletCreated, walletRef]); + + const persistBackup = useCallback((encryptedPrivateKey: string) => { + saveBackup(encryptedPrivateKey); + }, []); + + return { + unlocked, + setUnlocked, + refreshAddresses, + refreshCredentialCount, + loginWithPasskey, + createNewWalletFromUi, + ensureReady, + ensureOnboardedForSigning, + onSigningAuthenticated, + awaitSignerReady, + switchChain, + requestHide, + openCredentialList, + openCreateBackup, + openRestoreBackup, + persistBackup, + }; +} diff --git a/examples/general-wallet/tsconfig.json b/examples/general-wallet/tsconfig.json new file mode 100644 index 0000000..8da6ec9 --- /dev/null +++ b/examples/general-wallet/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client", "node"], + "baseUrl": ".", + "rootDir": "..", + "strict": true, + "skipLibCheck": true, + "paths": { + "@ows-shared": ["../shared/src/index.ts"] + } + }, + "include": ["src/**/*", "../shared/src/**/*", "vite.config.ts"] +} diff --git a/examples/general-wallet/vite.config.ts b/examples/general-wallet/vite.config.ts new file mode 100644 index 0000000..adc8e7f --- /dev/null +++ b/examples/general-wallet/vite.config.ts @@ -0,0 +1,150 @@ +import path from "node:path"; +import fs from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { fileURLToPath } from "node:url"; +import { defineConfig, type Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const signerPkgRoot = path.resolve(__dirname, "../../packages/ows-signer"); +const signerPkgSrc = path.join(signerPkgRoot, "src"); +const signerPublicDir = path.resolve(__dirname, "signer-static"); + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", +}; + +function contentType(filePath: string): string { + return MIME[path.extname(filePath)] ?? "application/octet-stream"; +} + +function sendFile(res: ServerResponse, filePath: string): void { + const stream = fs.createReadStream(filePath); + stream.once("open", () => { + res.statusCode = 200; + res.setHeader("Content-Type", contentType(filePath)); + stream.pipe(res); + }); + stream.once("error", (error: NodeJS.ErrnoException) => { + if (res.headersSent) { + res.destroy(error); + return; + } + const notFound = error.code === "ENOENT"; + res.statusCode = notFound ? 404 : 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(notFound ? "Not found" : "Internal Server Error"); + }); +} + +/** + * Resolve `rel` under `rootDir`, rejecting path traversal (`..`, encoded separators). + * Returns `undefined` when the result would escape `rootDir`. + */ +function resolveContainedPath( + rootDir: string, + urlRelative: string, +): string | undefined { + let rel: string; + try { + rel = decodeURIComponent(urlRelative); + } catch { + return undefined; + } + if (rel.includes("\0")) { + return undefined; + } + const root = path.resolve(rootDir); + const resolved = path.resolve(root, rel); + const rootPrefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + if (resolved !== root && !resolved.startsWith(rootPrefix)) { + return undefined; + } + return resolved; +} + +/** Serve `/signer/` outside Vite `base` so WebAuthn + nest stay same-origin. */ +function serveSignerPlugin(): Plugin { + return { + name: "ows-serve-signer", + configureServer(server) { + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => { + const url = req.url?.split("?")[0] ?? ""; + if (!url.startsWith("/signer")) { + next(); + return; + } + + if (url === "/signer" || url === "/signer/") { + sendFile(res, path.join(signerPublicDir, "index.html")); + return; + } + + if (url.startsWith("/signer/src/")) { + const filePath = resolveContainedPath( + signerPkgSrc, + url.slice("/signer/src/".length), + ); + if (!filePath || !fs.existsSync(filePath)) { + res.statusCode = 404; + res.end("Not found"); + return; + } + sendFile(res, filePath); + return; + } + + const filePath = resolveContainedPath( + signerPublicDir, + url.slice("/signer/".length), + ); + if (!filePath || !fs.existsSync(filePath)) { + res.statusCode = 404; + res.end("Not found"); + return; + } + sendFile(res, filePath); + }); + }, + }; +} + +export default defineConfig({ + base: "/wallet/", + publicDir: false, + plugins: [react(), tailwindcss(), serveSignerPlugin()], + resolve: { + alias: { + "@ows-shared": path.resolve(__dirname, "../shared/src/index.ts"), + }, + }, + server: { + port: Number(process.env.PORT ?? 5174), + strictPort: true, + host: "0.0.0.0", + allowedHosts: true, + fs: { + allow: [path.resolve(__dirname, "../..")], + }, + }, + preview: { + port: Number(process.env.PORT ?? 5174), + strictPort: true, + host: "0.0.0.0", + }, + build: { + outDir: "dist/wallet", + emptyOutDir: true, + // Do not ship source maps: they embed library strings (e.g. jose PKCS8 + // "BEGIN PRIVATE KEY" checks) that look like secrets in browser artifacts. + sourcemap: false, + }, +}); diff --git a/examples/host-dev.mjs b/examples/host-dev.mjs new file mode 100644 index 0000000..b75cb66 --- /dev/null +++ b/examples/host-dev.mjs @@ -0,0 +1,147 @@ +/** + * Shared helpers for OWS Host Layer example Vite servers + * (host, credential-issuer, credential-verifier). + * + * HTTPS: enable when HOST_HTTPS=1/true, or when default mkcert files exist under + * the example's certs/ directory (or HOST_SSL_CERT / HOST_SSL_KEY). + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const examplesDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(examplesDir, ".."); + +/** + * Resolve a path from env: absolute paths stay absolute; relative paths are + * anchored at the repo root (matches `.env.example` paths like + * `examples/host/certs/dev-cert.pem`), not `process.cwd()`. + * + * @param {string} filePath + */ +function resolveRepoPath(filePath) { + return path.isAbsolute(filePath) + ? filePath + : path.resolve(repoRoot, filePath); +} + +/** Host Layer demo hostname (hosts file → 127.0.0.1). */ +export const OWS_HOST_HOSTNAME = "ows-host.com"; +/** Credential issuer public hostname (credential `iss` / offer URIs). */ +export const OWS_ISSUER_HOSTNAME = "ows-issuer.com"; +/** Credential verifier public hostname (`request_uri` / `client_id`). */ +export const OWS_VERIFIER_HOSTNAME = "ows-verifier.com"; + +/** Default local ports for demos. */ +export const OWS_ISSUER_PORT = 5175; +export const OWS_VERIFIER_PORT = 5176; + +/** + * Public demo origin for issuer/verifier (not the bind address). + * Prefer env override, else `{scheme}://{hostname}:{port}`. + * + * @param {{ hostname: string, port: number, scheme: string, envValue?: string }} options + */ +export function demoPublicOrigin({ hostname, port, scheme, envValue }) { + const fromEnv = envValue?.trim(); + if (fromEnv) return fromEnv.replace(/\/$/, ""); + return `${scheme}://${hostname}:${port}`; +} + +/** mkcert SANs used by local HTTPS demos. */ +export const MKCERT_DEMO_SANS = [ + OWS_HOST_HOSTNAME, + OWS_ISSUER_HOSTNAME, + OWS_VERIFIER_HOSTNAME, + "localhost", + "127.0.0.1", +]; + +/** + * @param {{ certsDir: string, exampleLabel: string }} options + * @returns {{ key: Buffer, cert: Buffer } | undefined} + */ +export function resolveHttpsOptions({ certsDir, exampleLabel }) { + const flag = process.env.HOST_HTTPS?.trim().toLowerCase(); + const forceOn = flag === "1" || flag === "true" || flag === "yes"; + const forceOff = flag === "0" || flag === "false" || flag === "no"; + + const defaultCert = path.join(certsDir, "dev-cert.pem"); + const defaultKey = path.join(certsDir, "dev-key.pem"); + const hostFallbackCert = path.join(examplesDir, "host/certs/dev-cert.pem"); + const hostFallbackKey = path.join(examplesDir, "host/certs/dev-key.pem"); + + let certPath = process.env.HOST_SSL_CERT?.trim() + ? resolveRepoPath(process.env.HOST_SSL_CERT.trim()) + : defaultCert; + let keyPath = process.env.HOST_SSL_KEY?.trim() + ? resolveRepoPath(process.env.HOST_SSL_KEY.trim()) + : defaultKey; + + // Reuse examples/host mkcert files when this example has none of its own. + if ( + !process.env.HOST_SSL_CERT?.trim() && + !process.env.HOST_SSL_KEY?.trim() && + !(fs.existsSync(certPath) && fs.existsSync(keyPath)) && + fs.existsSync(hostFallbackCert) && + fs.existsSync(hostFallbackKey) + ) { + certPath = hostFallbackCert; + keyPath = hostFallbackKey; + } + + const certsPresent = fs.existsSync(certPath) && fs.existsSync(keyPath); + + if (forceOff) return undefined; + if (!forceOn && !certsPresent) return undefined; + + if (!certsPresent) { + throw new Error( + `HOST_HTTPS is set but cert/key missing for ${exampleLabel}.\n` + + ` Expected:\n` + + ` ${certPath}\n` + + ` ${keyPath}\n` + + ` Generate with:\n` + + ` mkcert -install\n` + + ` mkcert -cert-file examples/host/certs/dev-cert.pem -key-file examples/host/certs/dev-key.pem ${MKCERT_DEMO_SANS.join(" ")}\n` + + ` (issuer/verifier reuse examples/host/certs when present)`, + ); + } + + return { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }; +} + +/** Hostname only — accepts `immune-sheep-light.ngrok-free.app` or a full URL. */ +export function normalizeNgrokDomain(value) { + const raw = value?.trim(); + if (!raw) return undefined; + try { + const url = raw.includes("://") ? raw : `https://${raw}`; + return new URL(url).hostname; + } catch { + return raw.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + } +} + +/** + * Branding Layer iframe URL for Host Layer demos. + * + * Priority: + * 1. `WALLET_IFRAME_URL` — full URL override (any branding implementation) + * 2. `NGROK_DOMAIN` → `https:///wallet/` + * 3. `http://localhost:5174/wallet/` + */ +export function walletIframeUrl() { + const override = process.env.WALLET_IFRAME_URL?.trim(); + if (override) { + return override.replace(/\/?$/, "/"); + } + const domain = normalizeNgrokDomain(process.env.NGROK_DOMAIN); + if (domain) { + return `https://${domain}/wallet/`; + } + return "http://localhost:5174/wallet/"; +} diff --git a/examples/host/.env.example b/examples/host/.env.example new file mode 100644 index 0000000..5eb9ee6 --- /dev/null +++ b/examples/host/.env.example @@ -0,0 +1,8 @@ +# OWS example host + +# Branding Layer iframe URL is derived at build/dev time from repo root .env: +# NGROK_DOMAIN → https:///wallet/ +# (fallback) → http://localhost:5174/wallet/ +# +# Copy ../../.env.example to ../../.env and set NGROK_AUTHTOKEN + NGROK_DOMAIN +# when running the general-wallet dev server with ngrok. diff --git a/examples/host/README.md b/examples/host/README.md new file mode 100644 index 0000000..cdb2026 --- /dev/null +++ b/examples/host/README.md @@ -0,0 +1,83 @@ +# OWS Example Host + +Reference **Host Layer** application using `@1shotapi/ows-provider` for EIP-1193. + +## Run + +```bash +# Terminal 1 — Branding Layer + Signing Layer + ngrok (repo root) +npm run dev:general-wallet + +# Terminal 2 — Host Layer +npm run dev -w @1shotapi/ows-example-host +``` + +| Service | URL | +|---------|-----| +| Host Layer | http://localhost:5173 (or https — see below) | +| Branding Layer | `WALLET_IFRAME_URL`, or `https:///wallet/`, or http://localhost:5174/wallet/ | + +The host embeds the Branding Layer iframe URL from repo root `.env` (shared by issuer/verifier): + +1. `WALLET_IFRAME_URL` — full URL override (any branding implementation) +2. else `NGROK_DOMAIN` → `https:///wallet/` +3. else → `http://localhost:5174/wallet/` + +Copy [`.env.example`](../../.env.example) to `.env` at the repo root. Set `NGROK_AUTHTOKEN` + `NGROK_DOMAIN` for the general-wallet tunnel, or point `WALLET_IFRAME_URL` at another wallet. + +## Secure contexts (passkeys) + +WebAuthn only works in a **secure context**. An HTTPS branding/signer iframe embedded in an **HTTP** host is **not** secure — browsers walk the ancestor chain, so `window.isSecureContext` is `false` and `navigator.credentials` is missing even when `location.origin` is the ngrok HTTPS URL. + +Therefore the Host Layer must also be HTTPS (or `localhost` / `*.localhost` over HTTP) when testing passkeys with an HTTPS wallet iframe. + +### Local HTTPS with mkcert + +```bash +# Once per machine +mkcert -install + +# From repo root — SANs for hosts-file testing + localhost +mkcert -cert-file examples/host/certs/dev-cert.pem \ + -key-file examples/host/certs/dev-key.pem \ + ows-host.com ows-issuer.com ows-verifier.com localhost 127.0.0.1 +``` + +Optional hosts entry (`C:\Windows\System32\drivers\etc\hosts`): + +```text +127.0.0.1 ows-host.com ows-issuer.com ows-verifier.com +``` + +If `examples/host/certs/dev-cert.pem` and `dev-key.pem` exist, `npm run dev` enables HTTPS automatically for **host, credential-issuer, and credential-verifier** (shared helper in `examples/host-dev.mjs`). Or set in repo root `.env`: + +```bash +HOST_HTTPS=1 +# optional overrides: +# HOST_SSL_CERT=examples/host/certs/dev-cert.pem +# HOST_SSL_KEY=examples/host/certs/dev-key.pem +``` + +Then open **`https://ows-host.com:5173`** (accept the local CA once via `mkcert -install`). Credential demos use **`https://ows-issuer.com:5175`** / **`https://ows-verifier.com:5176`**. + +Confirm in the **signer** iframe console: + +```js +window.isSecureContext // true +navigator.credentials // CredentialsContainer +location.origin // https:///… +``` + +## E2E signing + +1. Start general-wallet dev (`npm run dev:general-wallet`) and host dev (above). +2. Open the host URL (HTTPS when using ngrok wallet). +3. Enter a message and click **Sign** +4. First sign triggers passkey creation in the branding/signing stack; subsequent signs reuse the stored credential. + +## Build + +```bash +npm run build -w @1shotapi/ows-example-host +npm run clean -w @1shotapi/ows-example-host +``` diff --git a/examples/host/certs/.gitkeep b/examples/host/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/host/index.html b/examples/host/index.html new file mode 100644 index 0000000..6a5f2a8 --- /dev/null +++ b/examples/host/index.html @@ -0,0 +1,94 @@ + + + + + + Open Wallet — Example Host + + + + +
+
+
+ + + +
+ + + +
+ + +
+

+ + +
+ + + +

+ + + +
+ +
+ + + +
+ +

Analytics

+

+ Events from branding via proxy.analytics (ows:analytics). +

+

+    
+
+ +
+ + + diff --git a/examples/host/package.json b/examples/host/package.json new file mode 100644 index 0000000..73e32f0 --- /dev/null +++ b/examples/host/package.json @@ -0,0 +1,25 @@ +{ + "name": "@1shotapi/ows-example-host", + "version": "0.0.0", + "private": true, + "description": "OWS reference Host Layer application — EIP-1193 via ows-provider", + "type": "module", + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "clean": "node scripts/clean.mjs", + "lint": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*", + "@wallet-standard/app": "^1.0.1", + "viem": "^2.52.0" + }, + "devDependencies": { + "dotenv": "^16.4.7", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } +} diff --git a/examples/host/scripts/clean.mjs b/examples/host/scripts/clean.mjs new file mode 100644 index 0000000..f5e4cb9 --- /dev/null +++ b/examples/host/scripts/clean.mjs @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +fs.rmSync(path.join(root, "dist"), { recursive: true, force: true }); +console.log("Removed dist/"); diff --git a/examples/host/scripts/dev.mjs b/examples/host/scripts/dev.mjs new file mode 100644 index 0000000..e181f86 --- /dev/null +++ b/examples/host/scripts/dev.mjs @@ -0,0 +1,95 @@ +/** + * Start Vite for the OWS example host. + * Loads NGROK_DOMAIN from repo root .env for Branding Layer iframe URL. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import net from "node:net"; +import { createServer } from "vite"; +import dotenv from "dotenv"; +import { resolveHttpsOptions, walletIframeUrl } from "../../host-dev.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const port = Number(process.env.PORT ?? 5173); + +let viteServer; +let shuttingDown = false; + +function waitForPort(host, listenPort, timeoutMs = 30_000) { + const started = Date.now(); + + return new Promise((resolve, reject) => { + const tryConnect = () => { + const socket = net.connect({ host, port: listenPort }, () => { + socket.end(); + resolve(undefined); + }); + + socket.on("error", () => { + if (Date.now() - started > timeoutMs) { + reject(new Error(`Timed out waiting for port ${listenPort}`)); + return; + } + setTimeout(tryConnect, 200); + }); + }; + + tryConnect(); + }); +} + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + + try { + if (viteServer) { + await viteServer.close(); + } + } catch (error) { + console.error(`Failed to shut down (${signal}):`, error); + process.exit(1); + return; + } + + process.exit(0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +try { + viteServer = await createServer({ + configFile: path.join(__dirname, "../vite.config.mjs"), + server: { port, host: "0.0.0.0" }, + }); + await viteServer.listen(); + await waitForPort("127.0.0.1", port); + + const https = resolveHttpsOptions({ + certsDir: path.join(__dirname, "../certs"), + exampleLabel: "examples/host", + }); + const scheme = https ? "https" : "http"; + const walletUrl = walletIframeUrl(); + console.log(`OWS example host: ${scheme}://localhost:${port}`); + console.log( + ` Also try: ${scheme}://ows-host.com:${port} (hosts file → 127.0.0.1)`, + ); + console.log(` Branding Layer iframe: ${walletUrl}`); + if (scheme === "http") { + console.log( + " Tip: HTTPS host is required for passkeys in nested HTTPS iframes — see examples/host/README.md", + ); + } +} catch (error) { + console.error("Failed to start host dev server:", error); + process.exit(1); +} diff --git a/examples/host/src/env.d.ts b/examples/host/src/env.d.ts new file mode 100644 index 0000000..e822314 --- /dev/null +++ b/examples/host/src/env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __WALLET_IFRAME_URL__: string; diff --git a/examples/host/src/main.ts b/examples/host/src/main.ts new file mode 100644 index 0000000..f8496e2 --- /dev/null +++ b/examples/host/src/main.ts @@ -0,0 +1,489 @@ +import { OWSProxy } from "@1shotapi/ows-provider"; +import { + ChainUtils, + EVMAccountAddress, + EVMChainId, + HexString, + SOLANA_DEVNET_CHAIN_ID, + SOLANA_MAINNET_CHAIN_ID, + type OWSChainId, +} from "@1shotapi/ows-types"; +import { getWallets } from "@wallet-standard/app"; +import { + createPublicClient, + custom, + encodeFunctionData, + erc20Abi, + formatUnits, + getAddress, + isAddress, + parseUnits, + type Address, + type Hex, +} from "viem"; +import "./styles.css"; + +const USDC_DECIMALS = 6; + +const HOST_CHAINS: ReadonlyArray<{ + chainId: OWSChainId; + label: string; + usdc: EVMAccountAddress | null; + blockExplorerUrl: string; +}> = [ + { + chainId: EVMChainId("0xaa36a7"), + label: "Sepolia", + usdc: EVMAccountAddress("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"), + blockExplorerUrl: "https://sepolia.etherscan.io", + }, + { + chainId: EVMChainId("0x14a34"), + label: "Base Sepolia", + usdc: EVMAccountAddress("0x036CbD53842c5426634e7929541eC2318f3dCF7e"), + blockExplorerUrl: "https://sepolia.basescan.org", + }, + { + chainId: EVMChainId("0x4cef52"), + label: "Arc Testnet", + usdc: EVMAccountAddress("0x3600000000000000000000000000000000000000"), + blockExplorerUrl: "https://testnet.arcscan.app", + }, + { + chainId: SOLANA_MAINNET_CHAIN_ID, + label: "Solana", + usdc: null, + blockExplorerUrl: "https://explorer.solana.com", + }, + { + chainId: SOLANA_DEVNET_CHAIN_ID, + label: "Solana Devnet", + usdc: null, + blockExplorerUrl: "https://explorer.solana.com/?cluster=devnet", + }, +]; + +type UsdcMode = "balance" | "send"; + +const messageInput = document.getElementById("message-input") as HTMLTextAreaElement; +const signButton = document.getElementById("sign-button") as HTMLButtonElement; +const showWalletButton = document.getElementById( + "show-wallet-button", +) as HTMLButtonElement; +const chainSelect = document.getElementById("chain-select") as HTMLSelectElement; +const chainRefreshButton = document.getElementById( + "chain-refresh-button", +) as HTMLButtonElement; +const usdcModeSelect = document.getElementById( + "usdc-mode-select", +) as HTMLSelectElement; +const usdcContractEl = document.getElementById("usdc-contract") as HTMLParagraphElement; +const usdcSendFields = document.getElementById("usdc-send-fields") as HTMLDivElement; +const usdcDestinationInput = document.getElementById( + "usdc-destination-input", +) as HTMLInputElement; +const usdcAmountInput = document.getElementById( + "usdc-amount-input", +) as HTMLInputElement; +const usdcActionButton = document.getElementById( + "usdc-action-button", +) as HTMLButtonElement; +const usdcOutput = document.getElementById("usdc-output") as HTMLPreElement; +const usdcTxLink = document.getElementById("usdc-tx-link") as HTMLParagraphElement; +const analyticsOutput = document.getElementById( + "analytics-output", +) as HTMLPreElement; +const statusEl = document.getElementById("status") as HTMLParagraphElement; +const signatureOutput = document.getElementById("signature-output") as HTMLPreElement; +const walletContainer = document.getElementById("wallet-container")!; + +function setStatus(message: string, isError = false): void { + statusEl.textContent = message; + statusEl.classList.toggle("status--error", isError); +} + +function normalizeChainIdHex(value: string): EVMChainId { + return EVMChainId(`0x${BigInt(value).toString(16)}`); +} + +function chainMeta(chainId: OWSChainId) { + return HOST_CHAINS.find((chain) => chain.chainId === chainId) ?? null; +} + +function setChainSelectValue(chainId: OWSChainId): void { + const value = String(chainId); + if ([...chainSelect.options].some((option) => option.value === value)) { + chainSelect.value = value; + return; + } + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + chainSelect.append(option); + chainSelect.value = value; +} + +function clearUsdcOutputs(): void { + usdcOutput.hidden = true; + usdcOutput.textContent = ""; + usdcTxLink.hidden = true; + usdcTxLink.textContent = ""; +} + +function selectedChainId(): OWSChainId { + const value = chainSelect.value; + if (ChainUtils.isSolanaChainId(value)) { + return value; + } + return EVMChainId(value as `0x${string}`); +} + +function syncUsdcUi(): void { + const mode = usdcModeSelect.value as UsdcMode; + const meta = chainMeta(selectedChainId()); + usdcSendFields.hidden = mode !== "send"; + usdcActionButton.textContent = + mode === "send" ? "Send USDC" : "Check Balance"; + usdcContractEl.textContent = meta?.usdc + ? `USDC: ${meta.usdc}` + : "USDC: unsupported chain"; + clearUsdcOutputs(); +} + +async function refreshChainFromWallet(proxy: OWSProxy): Promise { + const raw = await proxy.ethereum.request({ method: "eth_chainId" }); + const chainId = ChainUtils.isSolanaChainId(raw) + ? raw + : normalizeChainIdHex(String(raw)); + setChainSelectValue(chainId); + syncUsdcUi(); + return chainId; +} + +function createPublicClientFromProxy(proxy: OWSProxy) { + return createPublicClient({ + transport: custom(proxy.ethereum), + }); +} + +async function resolveAccount(proxy: OWSProxy): Promise { + let accounts = await proxy.ethereum.request({ method: "eth_accounts" }); + if (accounts.length === 0) { + accounts = await proxy.ethereum.request({ method: "eth_requestAccounts" }); + } + const account = accounts[0]; + if (!account) { + throw new Error("No account returned from wallet"); + } + return EVMAccountAddress(account); +} + +function explorerTxUrl(chainId: EVMChainId, hash: string): string | null { + const meta = chainMeta(chainId); + if (!meta) return null; + return `${meta.blockExplorerUrl}/tx/${hash}`; +} + +async function main(): Promise { + setStatus("Connecting to wallet…"); + syncUsdcUi(); + console.info("[ows-example-host] embedding Branding Layer", __WALLET_IFRAME_URL__); + + const proxy = await OWSProxy.create(walletContainer, __WALLET_IFRAME_URL__); + + // Opt-in Wallet Standard discovery (Solana Adapter / getWallets). + proxy.solana.registerSolanaWallet(); + console.info( + "[ows-example-host] Wallet Standard wallets", + getWallets() + .get() + .map((w) => w.name), + ); + + const analyticsLines: string[] = []; + proxy.analytics.on((event) => { + analyticsLines.unshift( + `${new Date().toISOString()} ${event.name} ${JSON.stringify(event)}`, + ); + analyticsOutput.textContent = analyticsLines.slice(0, 20).join("\n"); + console.info("[ows-example-host] analytics", event); + }); + + proxy.ethereum.on("chainChanged", (next) => { + try { + const chainId = normalizeChainIdHex(String(next)); + setChainSelectValue(chainId); + syncUsdcUi(); + setStatus(`Wallet switched to ${chainId}`); + } catch (error) { + console.error("[ows-example-host] chainChanged handler failed", error); + } + }); + + proxy.ethereum.on("accountsChanged", (accounts) => { + console.info("[ows-example-host] accountsChanged", accounts); + }); + + try { + const chainId = await refreshChainFromWallet(proxy); + setStatus(`Wallet connected on ${chainId}. Enter a message and click Sign.`); + } catch (error) { + setStatus( + error instanceof Error ? error.message : "Failed to read chain id", + true, + ); + } + + chainSelect.addEventListener("change", () => { + void (async () => { + const selected = selectedChainId(); + chainSelect.disabled = true; + clearUsdcOutputs(); + try { + if (ChainUtils.isSolanaChainId(selected)) { + await proxy.rpc("switchChain", { chainId: selected }); + } else { + await proxy.ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: selected as EVMChainId }], + }); + } + syncUsdcUi(); + setStatus(`Switched to ${selected}`); + } catch (error) { + await refreshChainFromWallet(proxy).catch(() => undefined); + setStatus( + error instanceof Error ? error.message : "Chain switch failed", + true, + ); + } finally { + chainSelect.disabled = false; + } + })(); + }); + + chainRefreshButton.addEventListener("click", () => { + void (async () => { + chainRefreshButton.disabled = true; + try { + const chainId = await refreshChainFromWallet(proxy); + setStatus(`Chain synced: ${chainId}`); + } catch (error) { + setStatus( + error instanceof Error ? error.message : "Failed to refresh chain", + true, + ); + } finally { + chainRefreshButton.disabled = false; + } + })(); + }); + + usdcModeSelect.addEventListener("change", () => { + syncUsdcUi(); + }); + + signButton.addEventListener("click", () => { + void handleSign(proxy); + }); + + usdcActionButton.addEventListener("click", () => { + const mode = usdcModeSelect.value as UsdcMode; + if (mode === "send") { + void handleSendUsdc(proxy); + } else { + void handleCheckUsdcBalance(proxy); + } + }); + + showWalletButton.addEventListener("click", () => { + proxy.showWallet(); + setStatus("Wallet panel shown. Use × in the wallet to hide."); + }); +} + +async function handleSign(proxy: OWSProxy): Promise { + const message = messageInput.value.trim(); + if (!message) { + setStatus("Enter a message to sign.", true); + return; + } + + signButton.disabled = true; + signatureOutput.hidden = true; + setStatus("Requesting accounts…"); + + try { + const account = await resolveAccount(proxy); + + setStatus("Approve the passkey prompt to sign…"); + + const signature = await proxy.ethereum.request({ + method: "personal_sign", + params: [message, account], + }); + + signatureOutput.textContent = signature; + signatureOutput.hidden = false; + setStatus(`Signed as ${account}`); + } catch (error) { + const messageText = + error instanceof Error ? error.message : "Signing failed"; + setStatus(messageText, true); + } finally { + signButton.disabled = false; + } +} + +async function handleCheckUsdcBalance(proxy: OWSProxy): Promise { + const chainId = selectedChainId(); + const meta = chainMeta(chainId); + if (!meta?.usdc || !ChainUtils.isEVMChainId(chainId)) { + setStatus("USDC is not configured for this chain.", true); + clearUsdcOutputs(); + return; + } + + usdcActionButton.disabled = true; + clearUsdcOutputs(); + setStatus("Reading USDC balance…"); + + try { + const token = getAddress(meta.usdc) as Address; + const account = await resolveAccount(proxy); + const owner = getAddress(account) as Address; + const client = createPublicClientFromProxy(proxy); + + const [name, symbol, balance, decimals] = await Promise.all([ + client.readContract({ + address: token, + abi: erc20Abi, + functionName: "name", + }), + client.readContract({ + address: token, + abi: erc20Abi, + functionName: "symbol", + }), + client.readContract({ + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }), + client.readContract({ + address: token, + abi: erc20Abi, + functionName: "decimals", + }), + ]); + + usdcOutput.textContent = [ + `Contract: ${token}`, + `Account: ${owner}`, + `Name: ${name}`, + `Symbol: ${symbol}`, + `Balance: ${formatUnits(balance, decimals)} ${symbol}`, + `Raw: ${balance.toString()}`, + ].join("\n"); + usdcOutput.hidden = false; + setStatus(`Balance for ${symbol}: ${formatUnits(balance, decimals)}`); + } catch (error) { + setStatus( + error instanceof Error ? error.message : "Failed to read USDC balance", + true, + ); + } finally { + usdcActionButton.disabled = false; + } +} + +async function handleSendUsdc(proxy: OWSProxy): Promise { + const chainId = selectedChainId(); + const meta = chainMeta(chainId); + if (!meta?.usdc || !ChainUtils.isEVMChainId(chainId)) { + setStatus("USDC is not configured for this chain.", true); + clearUsdcOutputs(); + return; + } + + const destinationRaw = usdcDestinationInput.value.trim(); + if (!isAddress(destinationRaw)) { + setStatus("Enter a valid destination address.", true); + clearUsdcOutputs(); + return; + } + + const amountRaw = usdcAmountInput.value.trim(); + let amount: bigint; + try { + amount = parseUnits(amountRaw, USDC_DECIMALS); + } catch { + setStatus("Enter a valid USDC amount.", true); + clearUsdcOutputs(); + return; + } + if (amount <= 0n) { + setStatus("Amount must be greater than zero.", true); + clearUsdcOutputs(); + return; + } + + usdcActionButton.disabled = true; + clearUsdcOutputs(); + setStatus("Preparing USDC transfer…"); + + try { + const account = await resolveAccount(proxy); + const to = getAddress(destinationRaw) as Address; + const data = encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [to, amount], + }) as Hex; + + setStatus("Approve the transaction in the wallet…"); + const hash = await proxy.ethereum.request({ + method: "eth_sendTransaction", + params: [ + { + from: account, + to: meta.usdc, + data: HexString(data), + value: HexString("0x0"), + chainId, + }, + ], + }); + + usdcOutput.textContent = `Transaction hash:\n${hash}`; + usdcOutput.hidden = false; + const url = explorerTxUrl(chainId, hash); + if (url) { + usdcTxLink.innerHTML = ""; + const link = document.createElement("a"); + link.href = url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = "View on explorer"; + usdcTxLink.append(link); + usdcTxLink.hidden = false; + } + setStatus(`USDC sent. Hash ${hash}`); + } catch (error) { + setStatus( + error instanceof Error ? error.message : "Failed to send USDC", + true, + ); + } finally { + usdcActionButton.disabled = false; + } +} + +main().catch((error: unknown) => { + console.error("[ows-example-host] failed to start", error); + setStatus( + error instanceof Error ? error.message : "Failed to connect to wallet", + true, + ); +}); diff --git a/examples/host/src/styles.css b/examples/host/src/styles.css new file mode 100644 index 0000000..d99beac --- /dev/null +++ b/examples/host/src/styles.css @@ -0,0 +1,170 @@ +:root { + color-scheme: light dark; + font-family: system-ui, -apple-system, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; + min-height: 100vh; + background: Canvas; + color: CanvasText; +} + +.site-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, transparent); +} + +.site-header h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.byline { + font-weight: 400; + opacity: 0.75; +} + +.content { + max-width: 40rem; + margin: 0 auto; + padding: 1.5rem; +} + +.sign-panel { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sign-panel label { + font-weight: 500; +} + +.chain-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.chain-row label { + flex: 0 0 auto; +} + +.chain-row select { + flex: 1 1 12rem; + min-width: 10rem; + padding: 0.375rem 0.5rem; + border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); + border-radius: 6px; + background: Canvas; + color: inherit; + font: inherit; +} + +.chain-refresh { + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); + border-radius: 6px; + background: color-mix(in srgb, CanvasText 8%, Canvas); + font: inherit; + font-size: 1.125rem; + line-height: 1; + cursor: pointer; +} + +.chain-refresh:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.sign-panel textarea, +.sign-panel .text-input { + width: 100%; + box-sizing: border-box; + padding: 0.625rem 0.75rem; + border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); + border-radius: 6px; + background: Canvas; + color: inherit; + font: inherit; +} + +.sign-panel textarea { + resize: vertical; +} + +.sign-panel .text-input { + font-family: ui-monospace, monospace; + font-size: 0.9rem; +} + +.panel-divider { + margin: 0.5rem 0 0; + border: none; + border-top: 1px solid color-mix(in srgb, CanvasText 15%, transparent); +} + +.panel-subtitle { + margin: 0.75rem 0 0; + font-size: 1rem; + font-weight: 600; +} + +.hint { + margin: 0; + font-size: 0.8rem; + opacity: 0.75; + word-break: break-all; +} + +.hint a { + color: inherit; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.actions button { + padding: 0.5rem 1.25rem; + border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); + border-radius: 6px; + background: color-mix(in srgb, CanvasText 8%, Canvas); + font: inherit; + cursor: pointer; +} + +.actions button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.status { + margin: 0; + min-height: 1.25rem; + font-size: 0.9rem; +} + +.status--error { + color: #c62828; +} + +.signature-output { + margin: 0; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); + border-radius: 6px; + font-family: ui-monospace, monospace; + font-size: 0.8rem; + word-break: break-all; + white-space: pre-wrap; +} diff --git a/examples/host/tsconfig.json b/examples/host/tsconfig.json new file mode 100644 index 0000000..b1f8f98 --- /dev/null +++ b/examples/host/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client"] + }, + "include": ["src/**/*"] +} diff --git a/examples/host/vite.config.mjs b/examples/host/vite.config.mjs new file mode 100644 index 0000000..fe075ba --- /dev/null +++ b/examples/host/vite.config.mjs @@ -0,0 +1,41 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import { defineConfig } from "vite"; +import { + resolveHttpsOptions, + walletIframeUrl, +} from "../host-dev.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "../.."); +dotenv.config({ path: path.join(repoRoot, ".env") }); + +const https = resolveHttpsOptions({ + certsDir: path.resolve(__dirname, "certs"), + exampleLabel: "examples/host", +}); + +export default defineConfig({ + define: { + __WALLET_IFRAME_URL__: JSON.stringify(walletIframeUrl()), + }, + server: { + port: Number(process.env.PORT ?? 5173), + host: "0.0.0.0", + allowedHosts: true, + strictPort: true, + ...(https ? { https } : {}), + }, + preview: { + port: Number(process.env.PORT ?? 5173), + host: "0.0.0.0", + strictPort: true, + ...(https ? { https } : {}), + }, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + }, +}); diff --git a/examples/shared/package.json b/examples/shared/package.json new file mode 100644 index 0000000..e2ea626 --- /dev/null +++ b/examples/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@1shotapi/ows-example-shared", + "version": "0.0.0", + "private": true, + "description": "Shared mock fixtures for OWS examples (not published)", + "type": "module", + "scripts": { + "test": "node --import tsx --test test/*.test.ts" + }, + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*", + "@sd-jwt/sd-jwt-vc": "^0.20.0" + }, + "devDependencies": { + "tsx": "^4.19.0" + } +} diff --git a/examples/shared/src/demo-flow.ts b/examples/shared/src/demo-flow.ts new file mode 100644 index 0000000..93e0532 --- /dev/null +++ b/examples/shared/src/demo-flow.ts @@ -0,0 +1,397 @@ +import type { + CredentialOfferInput, + CredentialReceipt, + PresentationRequestInput, + PresentationResult, + CredentialFilter, + CredentialSummary, + ICredentialRepository, + IOid4vciClient, + CredentialIssuanceContext, + IOid4vpClient, + PresentationBuildContext, + ICredentialStatusValidator, + KycProfilePolicy, + IHolderSigner, + IIssuerTrustRegistry, + CredentialIssuer, + CredentialId, +} from "@1shotapi/ows-types"; +import { + NoopCredentialStatusValidator, + ProofUtils, + PresentationUtils, + type CredentialClaimName, + type JWKThumbprint, +} from "@1shotapi/ows-types"; +import { verifySdJwtVcPresentation } from "@1shotapi/ows-provider"; +import type { SdJwtVcPayload } from "@sd-jwt/sd-jwt-vc"; +import { MockOid4vciClient } from "./mock/oid4vci.js"; +import { MockOid4vpClient } from "./mock/oid4vp.js"; +import { InMemoryCredentialRepository } from "./in-memory-store.js"; +import { InMemoryIssuerTrustRegistry } from "./in-memory-trust-registry.js"; +import { + MOCK_KYC_OFFER_URI, + MOCK_KYC_PRESENTATION_URI, + MOCK_KYC_PRESENTATION_REQUEST, + MOCK_OID4VCI_PROOF_NONCE, +} from "./fixtures.js"; +import { createDemoHolderSigner } from "./demo/jwk-holder-signer.js"; +import { DEMO_ISSUER_PUBLIC_JWK } from "./demo/demo-keys.js"; + +export type DemoCredentialFlowDeps = { + repository?: ICredentialRepository; + oid4vci?: IOid4vciClient; + oid4vp?: IOid4vpClient; + status?: ICredentialStatusValidator; + trust?: IIssuerTrustRegistry; + approvePresentation?: () => Promise; + holderSigner?: IHolderSigner; +}; + +/** Orchestrates mock issuance → storage → presentation for tests and demos. */ +export class DemoCredentialFlow { + readonly repository: ICredentialRepository; + readonly oid4vci: IOid4vciClient; + readonly oid4vp: IOid4vpClient; + readonly status: ICredentialStatusValidator; + readonly trust: IIssuerTrustRegistry; + private readonly approvePresentation: () => Promise; + private readonly holderSigner: IHolderSigner; + + constructor(deps: DemoCredentialFlowDeps = {}) { + this.repository = deps.repository ?? new InMemoryCredentialRepository(); + this.oid4vci = deps.oid4vci ?? new MockOid4vciClient(); + this.oid4vp = deps.oid4vp ?? new MockOid4vpClient(); + this.status = deps.status ?? new NoopCredentialStatusValidator(); + this.trust = deps.trust ?? new InMemoryIssuerTrustRegistry(); + this.approvePresentation = deps.approvePresentation ?? (async () => true); + this.holderSigner = deps.holderSigner ?? createDemoHolderSigner(); + } + + private async buildIssuanceContext( + credentialIssuer: string, + ): Promise { + const holderPublicKeyJwk = await this.holderSigner.publicKeyJwk(); + const nonce = MOCK_OID4VCI_PROOF_NONCE; + const jwt = await ProofUtils.buildOid4vciProofJwt({ + holderSigner: this.holderSigner, + audience: credentialIssuer, + nonce, + }); + return { + holderPublicKeyJwk, + proof: { proof_type: "jwt", jwt }, + nonce, + }; + } + + private presentationContext(): PresentationBuildContext { + return { holderSigner: this.holderSigner }; + } + + private async assertActive( + credential: Awaited>, + ): Promise { + if (!credential) { + throw new Error("Credential not found"); + } + const check = await this.status.checkStatus(credential); + if (check.status !== "active") { + throw new Error( + `Credential status is ${check.status}${check.details ? `: ${check.details}` : ""}`, + ); + } + } + + async acceptOffer( + input: CredentialOfferInput = { + credentialOfferUri: MOCK_KYC_OFFER_URI, + }, + ): Promise { + const uri = input.credentialOfferUri; + const offer = + input.offer ?? + (uri ? await this.oid4vci.resolveOffer(uri) : undefined); + if (!offer) { + throw new Error("credentialOfferUri or offer is required"); + } + + const trusted = await this.trust.isTrustedIssuer(offer.credentialIssuer); + if (!trusted) { + throw new Error(`Untrusted issuer: ${offer.credentialIssuer}`); + } + + const metadata = await this.oid4vci.fetchIssuerMetadata( + offer.credentialIssuer, + ); + const stored = await this.oid4vci.requestCredential( + offer, + metadata, + await this.buildIssuanceContext(metadata.credentialIssuer), + ); + await this.assertActive(stored); + await this.repository.store(stored); + + return { + credentialId: stored.credentialId, + format: stored.format, + type: stored.type, + }; + } + + async present( + input: PresentationRequestInput = { + requestUri: MOCK_KYC_PRESENTATION_URI, + }, + ): Promise { + const uri = input.requestUri; + const definition = + input.request ?? + (uri ? await this.oid4vp.resolveRequest(uri) : undefined); + if (!definition) { + throw new Error("requestUri or request is required"); + } + + const summaries = await this.repository.list(); + let matches = await this.oid4vp.matchCredentials(definition, summaries); + + if (input.acceptedIssuers && input.acceptedIssuers.length > 0) { + const allowed = new Set(input.acceptedIssuers); + matches = matches.filter((m) => allowed.has(m.issuer)); + } + + if (matches.length === 0) { + throw new Error("No matching credentials in wallet"); + } + + const approved = await this.approvePresentation(); + if (!approved) { + throw new Error("User rejected presentation"); + } + + const credential = await this.repository.get(matches[0]!.credentialId); + await this.assertActive(credential); + + return this.oid4vp.buildPresentation( + credential!, + definition, + this.presentationContext(), + ); + } + + async list(filter?: CredentialFilter): Promise { + return this.repository.list(filter); + } + + async delete(credentialId: CredentialId): Promise { + await this.repository.delete(credentialId); + } +} + +export type CustodyStepId = + | "issuer_signature" + | "issuer_allowed" + | "required_claims" + | "holder_cnf" + | "kb_signature" + | "kb_binding"; + +export type CustodyStep = { + id: CustodyStepId; + label: string; + ok: boolean; + detail: string; +}; + +export type MockVerifierResult = { + valid: boolean; + reasons: string[]; + /** Disclosed claim name → value (from crypto verify payload). */ + disclosedClaims: Record; + issuer?: string; + vct?: string; + format: string; + holderJwk?: JsonWebKey; + holderThumbprint?: JWKThumbprint; + kb?: { + nonce?: string; + aud?: string; + iat?: number; + }; + custody: CustodyStep[]; +}; + +function claimValuesFromPayload( + payload: Record | undefined, + requested: CredentialClaimName[], +): Record { + const out: Record = {}; + if (!payload) return out; + for (const name of requested) { + if (name in payload) { + out[name] = payload[name]; + } + } + return out; +} + +/** MOCK verifier — policy + crypto + chain-of-custody inspection. */ +export async function validateMockPresentation( + presentation: PresentationResult, + policy: KycProfilePolicy, + issuerId: CredentialIssuer, + holderPublicKeyJwk?: JsonWebKey, + options?: { + expectedNonce?: string; + expectedAudience?: string; + issuerPublicKeyJwk?: JsonWebKey; + }, +): Promise { + const reasons: string[] = []; + const expectedNonce = + options?.expectedNonce ?? MOCK_KYC_PRESENTATION_REQUEST.nonce!; + const expectedAudience = + options?.expectedAudience ?? + MOCK_KYC_PRESENTATION_REQUEST.audience ?? + MOCK_KYC_PRESENTATION_REQUEST.verifier.id; + const issuerPublicKeyJwk = + options?.issuerPublicKeyJwk ?? DEMO_ISSUER_PUBLIC_JWK; + + const issuerClaims = PresentationUtils.decodeIssuerClaims( + presentation.presentation, + ); + const presentationIssuer = issuerClaims.iss ?? String(issuerId); + const vct = issuerClaims.vct; + + const holderJwk = + holderPublicKeyJwk ?? + PresentationUtils.extractHolderJwk(presentation.presentation) ?? + issuerClaims.cnf?.jwk; + + const kb = PresentationUtils.extractKbJwtClaims(presentation.presentation); + const holderThumbprint = holderJwk + ? await ProofUtils.jwkThumbprint(holderJwk) + : undefined; + + const missingClaims = policy.requiredClaims.filter( + (claim) => !presentation.disclosedClaims.includes(claim), + ); + const issuerAllowed = policy.allowedIssuers.some( + (allowed) => allowed === presentationIssuer || allowed === issuerId, + ); + + for (const claim of missingClaims) { + reasons.push(`Missing required claim: ${claim}`); + } + if (!issuerAllowed) { + reasons.push(`Issuer not in allowed list: ${presentationIssuer}`); + } + if (!holderJwk) { + reasons.push("Missing holder cnf.jwk in SD-JWT VC presentation"); + } + + let cryptoValid = false; + let cryptoReasons: string[] = []; + let payload: SdJwtVcPayload | undefined; + + if (holderJwk) { + const cryptoResult = await verifySdJwtVcPresentation({ + presentation: presentation.presentation, + issuerPublicKeyJwk, + holderPublicKeyJwk: holderJwk, + nonce: expectedNonce, + audience: expectedAudience, + }); + cryptoValid = cryptoResult.valid; + cryptoReasons = cryptoResult.reasons; + payload = cryptoResult.payload; + if (!cryptoValid) { + reasons.push(...cryptoReasons); + } + } + + const disclosedClaims = claimValuesFromPayload( + payload, + policy.requiredClaims, + ); + for (const name of presentation.disclosedClaims) { + if (!(name in disclosedClaims) && payload && name in payload) { + disclosedClaims[name] = payload[name]; + } + } + + const kbBindingOk = + kb?.nonce === expectedNonce && kb?.aud === expectedAudience; + + const custody: CustodyStep[] = [ + { + id: "issuer_signature", + label: "Issuer signed SD-JWT VC", + ok: cryptoValid, + detail: cryptoValid + ? `Verified with demo issuer key (${presentationIssuer})` + : (cryptoReasons.find((r) => !/audience|nonce/i.test(r)) ?? + (holderJwk + ? "Issuer or presentation signature failed" + : "Skipped — no holder key")), + }, + { + id: "issuer_allowed", + label: "Issuer allowed by policy", + ok: issuerAllowed, + detail: issuerAllowed + ? `${presentationIssuer} is on the allow list` + : `${presentationIssuer} is not allowed`, + }, + { + id: "required_claims", + label: "Required claims disclosed", + ok: missingClaims.length === 0, + detail: + missingClaims.length === 0 + ? policy.requiredClaims.join(", ") + : `Missing: ${missingClaims.join(", ")}`, + }, + { + id: "holder_cnf", + label: "Holder bound via cnf.jwk", + ok: Boolean(holderJwk), + detail: holderThumbprint + ? `thumbprint ${holderThumbprint}` + : "No cnf.jwk in credential", + }, + { + id: "kb_signature", + label: "kb+jwt verifies under cnf.jwk", + ok: cryptoValid, + detail: cryptoValid + ? "Key-binding signature valid" + : (cryptoReasons.find((r) => /kb|key.?binding|signature/i.test(r)) ?? + "Key-binding verification failed"), + }, + { + id: "kb_binding", + label: "kb+jwt nonce and audience match request", + ok: kbBindingOk, + detail: kb + ? `nonce=${kb.nonce ?? "—"} aud=${kb.aud ?? "—"} (expected nonce=${expectedNonce} aud=${expectedAudience})` + : "Missing kb+jwt", + }, + ]; + + return { + valid: reasons.length === 0 && custody.every((step) => step.ok), + reasons, + disclosedClaims, + issuer: presentationIssuer, + vct, + format: presentation.format, + holderJwk, + holderThumbprint, + kb: kb + ? { nonce: kb.nonce, aud: kb.aud, iat: kb.iat } + : undefined, + custody, + }; +} diff --git a/examples/shared/src/demo/cors.ts b/examples/shared/src/demo/cors.ts new file mode 100644 index 0000000..d46fbc0 --- /dev/null +++ b/examples/shared/src/demo/cors.ts @@ -0,0 +1,42 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin } from "vite"; + +/** Open CORS for OID4 demo servers (wallet iframe originates on ngrok / other hosts). */ +export function applyDemoCors(res: ServerResponse): void { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + // Public HTTPS wallet (ngrok) → local issuer/verifier triggers Chrome Private Network Access. + res.setHeader("Access-Control-Allow-Private-Network", "true"); +} + +type NextFn = (err?: unknown) => void; + +/** + * Connect middleware: stamp open CORS on every response and short-circuit OPTIONS. + * Register with Vite `server.cors: false` so Vite's localhost-only CORS does not win. + */ +export function demoCorsMiddleware() { + return (req: IncomingMessage, res: ServerResponse, next: NextFn) => { + applyDemoCors(res); + if (req.method === "OPTIONS") { + res.statusCode = 204; + res.end(); + return; + } + next(); + }; +} + +/** Vite plugin: demo CORS for issuer/verifier HTTP surfaces. */ +export function demoCorsPlugin(): Plugin { + return { + name: "ows-demo-cors", + configureServer(server) { + server.middlewares.use(demoCorsMiddleware()); + }, + configurePreviewServer(server) { + server.middlewares.use(demoCorsMiddleware()); + }, + }; +} diff --git a/examples/shared/src/demo/demo-keys.ts b/examples/shared/src/demo/demo-keys.ts new file mode 100644 index 0000000..1c37d45 --- /dev/null +++ b/examples/shared/src/demo/demo-keys.ts @@ -0,0 +1,32 @@ +/** + * MOCK demo Ed25519 keypairs — TEST ONLY. Do not use in production. + * Generated once via Node `generateKeyPairSync('ed25519')`. + */ + +/** Demo issuer — signs mock KYC SD-JWT VCs. */ +export const DEMO_ISSUER_PRIVATE_JWK: JsonWebKey = { + crv: "Ed25519", + d: "pcLAFtmJw-OtcPm7taGhEDocf63HfBk5TMXWSoF6rvw", + x: "ObRX6jKS0AbsXx3ICSIiozuqzYUXXrQhlj4GkeqXfbs", + kty: "OKP", +}; + +export const DEMO_ISSUER_PUBLIC_JWK: JsonWebKey = { + crv: "Ed25519", + x: "ObRX6jKS0AbsXx3ICSIiozuqzYUXXrQhlj4GkeqXfbs", + kty: "OKP", +}; + +/** Fallback demo holder when branding does not supply a wallet `IHolderSigner`. */ +export const DEMO_HOLDER_PRIVATE_JWK: JsonWebKey = { + crv: "Ed25519", + d: "G8_8M9RjEA5L5NIeAoB24C9yOb8KPuRoh9y7SidoXJA", + x: "3dmWOAMTtkMxm88aJ9QhlK5SWimNXt6-WTsI4eFHwC8", + kty: "OKP", +}; + +export const DEMO_HOLDER_PUBLIC_JWK: JsonWebKey = { + crv: "Ed25519", + x: "3dmWOAMTtkMxm88aJ9QhlK5SWimNXt6-WTsI4eFHwC8", + kty: "OKP", +}; diff --git a/examples/shared/src/demo/issuer.ts b/examples/shared/src/demo/issuer.ts new file mode 100644 index 0000000..bbba985 --- /dev/null +++ b/examples/shared/src/demo/issuer.ts @@ -0,0 +1,44 @@ +import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"; +import { + CredentialCryptoUtils, + type CredentialClaimName, +} from "@1shotapi/ows-types"; +import { DEMO_ISSUER_PRIVATE_JWK, DEMO_ISSUER_PUBLIC_JWK } from "./demo-keys.js"; + +export type IssueSdJwtVcInput = { + issuer: string; + vct: string; + claims: Record; + /** Claim keys eligible for selective disclosure. */ + disclosableClaims: CredentialClaimName[]; + holderPublicKeyJwk: JsonWebKey; + iat?: number; +}; + +/** Issues a demo SD-JWT VC using the mock issuer keypair. */ +export async function issueDemoSdJwtVc(input: IssueSdJwtVcInput): Promise { + const sdjwt = new SDJwtVcInstance({ + signer: CredentialCryptoUtils.createEd25519Signer(DEMO_ISSUER_PRIVATE_JWK), + signAlg: "EdDSA", + verifier: CredentialCryptoUtils.createEd25519Verifier(DEMO_ISSUER_PUBLIC_JWK), + hasher: CredentialCryptoUtils.hasher, + hashAlg: "sha-256", + saltGenerator: CredentialCryptoUtils.saltGenerator, + }); + + const iat = input.iat ?? Math.floor(Date.now() / 1000); + const disclosureFrame = { + _sd: input.disclosableClaims.map(String), + }; + + return sdjwt.issue( + { + iss: input.issuer, + iat, + vct: input.vct, + cnf: { jwk: input.holderPublicKeyJwk }, + ...input.claims, + } as Parameters[0], + disclosureFrame as unknown as Parameters[1], + ); +} diff --git a/examples/shared/src/demo/jwk-holder-signer.ts b/examples/shared/src/demo/jwk-holder-signer.ts new file mode 100644 index 0000000..4cfb7e2 --- /dev/null +++ b/examples/shared/src/demo/jwk-holder-signer.ts @@ -0,0 +1,26 @@ +import { + CredentialCryptoUtils, + type IHolderSigner, +} from "@1shotapi/ows-types"; +import { DEMO_HOLDER_PRIVATE_JWK } from "./demo-keys.js"; + +/** `IHolderSigner` from an Ed25519 JWK private key (Web Crypto). */ +export function createEd25519HolderSignerFromJwk( + privateJwk: JsonWebKey, +): IHolderSigner { + const publicJwk = { ...privateJwk }; + delete publicJwk.d; + return { + async publicKeyJwk() { + return publicJwk; + }, + async signKbJwt(unsignedJwt) { + return CredentialCryptoUtils.signEd25519(privateJwk, unsignedJwt); + }, + }; +} + +/** Demo holder signer for mocks/tests when no wallet key is available. */ +export function createDemoHolderSigner(): IHolderSigner { + return createEd25519HolderSignerFromJwk(DEMO_HOLDER_PRIVATE_JWK); +} diff --git a/examples/shared/src/fixtures.ts b/examples/shared/src/fixtures.ts new file mode 100644 index 0000000..45cc35e --- /dev/null +++ b/examples/shared/src/fixtures.ts @@ -0,0 +1,122 @@ +import { + CredentialClaimName, + CredentialConfigurationId, + CredentialId, + CredentialIssuer, + CredentialOfferUri, + CredentialTypeName, + ISO8601DateTime, + PresentationRequestUri, + UriString, + type StoredCredential, + type CredentialOffer, + type PresentationDefinition, + type IssuerTrustMetadata, + type KycProfilePolicy, +} from "@1shotapi/ows-types"; +import { issueDemoSdJwtVc } from "./demo/issuer.js"; +import { DEMO_HOLDER_PUBLIC_JWK } from "./demo/demo-keys.js"; + +/** MOCK KYC issuer — signs demo SD-JWT VCs with a fixed test keypair. */ +export const MOCK_KYC_ISSUER_ID = CredentialIssuer( + "https://kyc.demo.issuer.example", +); + +export const MOCK_KYC_OFFER_URI = CredentialOfferUri("mock://kyc-offer/demo"); + +export const MOCK_KYC_PRESENTATION_URI = PresentationRequestUri( + "mock://kyc-presentation/demo", +); + +export const MOCK_CREDENTIAL_ID = CredentialId("cred_mock_kyc_001"); + +export const MOCK_KYC_OFFER: CredentialOffer = { + credentialIssuer: MOCK_KYC_ISSUER_ID, + credentialConfigurationIds: [CredentialConfigurationId("KycCredential")], +}; + +export const MOCK_KYC_PRESENTATION_REQUEST: PresentationDefinition = { + id: "mock-presentation-001", + verifier: { + id: UriString("https://verifier.demo.example"), + name: "Demo Verifier", + }, + requestedClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + credentialTypes: [CredentialTypeName("KycCredential")], + nonce: "mock-nonce-abc", + audience: "https://verifier.demo.example", +}; + +/** C-nonce echoed in OID4VCI proof JWTs for the mock issuer. */ +export const MOCK_OID4VCI_PROOF_NONCE = "mock-issuance-nonce"; + +export const MOCK_ISSUER_TRUST: IssuerTrustMetadata = { + issuerId: MOCK_KYC_ISSUER_ID, + name: "Demo KYC Issuer", + assuranceLevels: ["substantial", "high"], + jurisdictions: ["US", "GB"], +}; + +export const MOCK_KYC_POLICY: KycProfilePolicy = { + requiredClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + minAssuranceLevel: "substantial", + maxAgeDays: 365, + allowedIssuers: [MOCK_KYC_ISSUER_ID], +}; + +export async function createMockStoredCredential(): Promise { + const issuedAt = ISO8601DateTime(new Date().toISOString()); + const validUntil = ISO8601DateTime( + new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + ); + const subject = { + ageOver18: true, + country: "US", + assuranceLevel: "substantial", + verifiedAt: issuedAt, + }; + const payload = await issueDemoSdJwtVc({ + issuer: MOCK_KYC_ISSUER_ID, + vct: "KycCredential", + claims: subject, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + CredentialClaimName("assuranceLevel"), + CredentialClaimName("verifiedAt"), + ], + holderPublicKeyJwk: DEMO_HOLDER_PUBLIC_JWK, + }); + return { + credentialId: MOCK_CREDENTIAL_ID, + format: "sd-jwt-vc", + type: [ + CredentialTypeName("VerifiableCredential"), + CredentialTypeName("KycCredential"), + ], + issuer: MOCK_KYC_ISSUER_ID, + issuedAt, + validUntil, + payload, + semantic: { + type: [ + CredentialTypeName("VerifiableCredential"), + CredentialTypeName("KycCredential"), + ], + issuer: MOCK_KYC_ISSUER_ID, + validFrom: issuedAt, + validUntil, + credentialSubject: subject, + credentialSchema: { + id: UriString("https://schemas.ows.example/kyc/v1"), + type: "JsonSchema", + }, + }, + }; +} diff --git a/examples/shared/src/in-memory-store.ts b/examples/shared/src/in-memory-store.ts new file mode 100644 index 0000000..93ec0ad --- /dev/null +++ b/examples/shared/src/in-memory-store.ts @@ -0,0 +1,58 @@ +import type { CredentialId } from "@1shotapi/ows-types"; +import type { + ICredentialRepository, + StoredCredential, + CredentialFilter, + CredentialSummary, +} from "@1shotapi/ows-types"; + +export class InMemoryCredentialRepository implements ICredentialRepository { + private readonly credentials = new Map(); + private readonly revoked = new Set(); + + async store(credential: StoredCredential): Promise { + this.credentials.set(credential.credentialId, credential); + this.revoked.delete(credential.credentialId); + } + + async get(credentialId: CredentialId): Promise { + if (this.revoked.has(credentialId)) { + return undefined; + } + return this.credentials.get(credentialId); + } + + async list(filter?: CredentialFilter): Promise { + const all = [...this.credentials.values()].filter( + (c) => !this.revoked.has(c.credentialId), + ); + const filtered = all.filter((c) => { + if (filter?.type && !c.type.includes(filter.type)) { + return false; + } + if (filter?.issuer && c.issuer !== filter.issuer) { + return false; + } + return true; + }); + + return filtered.map((c) => ({ + credentialId: c.credentialId, + type: c.type, + issuer: c.issuer, + format: c.format, + issuedAt: c.issuedAt, + validUntil: c.validUntil, + })); + } + + async delete(credentialId: CredentialId): Promise { + this.credentials.delete(credentialId); + } + + async revoke(credentialId: CredentialId): Promise { + if (this.credentials.has(credentialId)) { + this.revoked.add(credentialId); + } + } +} diff --git a/examples/shared/src/in-memory-trust-registry.ts b/examples/shared/src/in-memory-trust-registry.ts new file mode 100644 index 0000000..f4118e8 --- /dev/null +++ b/examples/shared/src/in-memory-trust-registry.ts @@ -0,0 +1,58 @@ +import type { CredentialIssuer } from "@1shotapi/ows-types"; +import type { IIssuerTrustRegistry, IssuerTrustMetadata } from "@1shotapi/ows-types"; +import { MOCK_ISSUER_TRUST } from "./fixtures.js"; + +const LOCAL_DEMO_HOSTNAMES = new Set([ + "localhost", + "127.0.0.1", + "ows-host.com", + "ows-issuer.com", + "ows-verifier.com", +]); + +/** True for local Vite / mkcert demo issuer origins. */ +export function isLocalDemoIssuerOrigin(issuerId: string): boolean { + try { + const url = new URL(issuerId); + return ( + LOCAL_DEMO_HOSTNAMES.has(url.hostname) || + url.hostname.endsWith(".ows-host.com") || + url.hostname.endsWith(".ows-issuer.com") || + url.hostname.endsWith(".ows-verifier.com") + ); + } catch { + return false; + } +} + +export class InMemoryIssuerTrustRegistry implements IIssuerTrustRegistry { + constructor( + private readonly entries: IssuerTrustMetadata[] = [MOCK_ISSUER_TRUST], + ) {} + + async isTrustedIssuer(issuerId: CredentialIssuer): Promise { + const id = String(issuerId); + if (isLocalDemoIssuerOrigin(id)) return true; + return this.entries.some((e) => e.issuerId === issuerId); + } + + async resolveIssuer( + issuerId: CredentialIssuer, + ): Promise { + const found = this.entries.find((e) => e.issuerId === issuerId); + if (found) return found; + if (isLocalDemoIssuerOrigin(String(issuerId))) { + return { + issuerId, + name: "Local Demo KYC Issuer", + assuranceLevels: ["substantial", "high"], + jurisdictions: ["US", "GB"], + }; + } + return undefined; + } + + async listIssuers(): Promise { + return [...this.entries]; + } +} diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts new file mode 100644 index 0000000..7a5586d --- /dev/null +++ b/examples/shared/src/index.ts @@ -0,0 +1,23 @@ +export * from "./fixtures.js"; +export * from "./in-memory-store.js"; +export { + LocalStorageCredentialRepository, + OWS_MOCK_CREDENTIALS_STORAGE_KEY, + createMemoryStorageBackend, + type CredentialStorageBackend, +} from "./local-storage-store.js"; +export { MockOid4vciClient, DEMO_HOLDER_PUBLIC_JWK } from "./mock/oid4vci.js"; +export { MockOid4vpClient } from "./mock/oid4vp.js"; +export { + DEMO_HOLDER_PRIVATE_JWK, + DEMO_ISSUER_PUBLIC_JWK, +} from "./demo/demo-keys.js"; +export { InMemoryIssuerTrustRegistry, isLocalDemoIssuerOrigin } from "./in-memory-trust-registry.js"; +export { + DemoCredentialFlow, + validateMockPresentation, + type DemoCredentialFlowDeps, + type MockVerifierResult, + type CustodyStep, + type CustodyStepId, +} from "./demo-flow.js"; diff --git a/examples/shared/src/local-storage-store.ts b/examples/shared/src/local-storage-store.ts new file mode 100644 index 0000000..ceb0b07 --- /dev/null +++ b/examples/shared/src/local-storage-store.ts @@ -0,0 +1,160 @@ +import type { CredentialId } from "@1shotapi/ows-types"; +import type { + ICredentialRepository, + StoredCredential, + CredentialFilter, + CredentialSummary, +} from "@1shotapi/ows-types"; + +/** localStorage key for demo credentials (shared across wallet iframe loads on one origin). */ +export const OWS_MOCK_CREDENTIALS_STORAGE_KEY = "ows.mock.credentials.v1"; + +/** Minimal sync key/value API (localStorage or test double). */ +export type CredentialStorageBackend = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +type StoredBlob = { + credentials: Record; + revoked: string[]; +}; + +function summarize( + credentials: Iterable, + filter?: CredentialFilter, +): CredentialSummary[] { + const filtered = [...credentials].filter((c) => { + if (filter?.type && !c.type.includes(filter.type)) { + return false; + } + if (filter?.issuer && c.issuer !== filter.issuer) { + return false; + } + return true; + }); + + return filtered.map((c) => ({ + credentialId: c.credentialId, + type: c.type, + issuer: c.issuer, + format: c.format, + issuedAt: c.issuedAt, + validUntil: c.validUntil, + })); +} + +/** MOCK — persists credentials in localStorage for multi-tab demo flows (plaintext). */ +export class LocalStorageCredentialRepository implements ICredentialRepository { + private readonly storageKey: string; + private readonly storage: CredentialStorageBackend; + + constructor( + options: { + storageKey?: string; + storage?: CredentialStorageBackend; + } = {}, + ) { + this.storageKey = + options.storageKey ?? OWS_MOCK_CREDENTIALS_STORAGE_KEY; + this.storage = + options.storage ?? + (typeof localStorage !== "undefined" + ? localStorage + : createMemoryStorageBackend()); + } + + async store(credential: StoredCredential): Promise { + const blob = this.readBlob(); + blob.credentials[credential.credentialId] = credential; + blob.revoked = blob.revoked.filter((id) => id !== credential.credentialId); + this.writeBlob(blob); + } + + async get(credentialId: CredentialId): Promise { + const blob = this.readBlob(); + if (blob.revoked.includes(credentialId)) { + return undefined; + } + return blob.credentials[credentialId]; + } + + async list(filter?: CredentialFilter): Promise { + const blob = this.readBlob(); + const active = Object.values(blob.credentials).filter( + (c) => !blob.revoked.includes(c.credentialId), + ); + return summarize(active, filter); + } + + async delete(credentialId: CredentialId): Promise { + const blob = this.readBlob(); + delete blob.credentials[credentialId]; + this.writeBlob(blob); + } + + async revoke(credentialId: CredentialId): Promise { + const blob = this.readBlob(); + if (blob.credentials[credentialId] && !blob.revoked.includes(credentialId)) { + blob.revoked.push(credentialId); + this.writeBlob(blob); + } + } + + private readBlob(): StoredBlob { + const raw = this.storage.getItem(this.storageKey); + if (!raw) { + return { credentials: {}, revoked: [] }; + } + + try { + const parsed = JSON.parse(raw) as + | StoredBlob + | Record; + if ( + parsed && + typeof parsed === "object" && + "credentials" in parsed && + typeof (parsed as StoredBlob).credentials === "object" + ) { + const blob = parsed as StoredBlob; + return { + credentials: blob.credentials ?? {}, + revoked: Array.isArray(blob.revoked) ? blob.revoked : [], + }; + } + // Legacy shape: flat id → credential map + return { + credentials: parsed as Record, + revoked: [], + }; + } catch { + return { credentials: {}, revoked: [] }; + } + } + + private writeBlob(blob: StoredBlob): void { + const credCount = Object.keys(blob.credentials).length; + if (credCount === 0 && blob.revoked.length === 0) { + this.storage.removeItem(this.storageKey); + return; + } + + this.storage.setItem(this.storageKey, JSON.stringify(blob)); + } +} + +/** In-memory backend for Node tests and SSR fallbacks. */ +export function createMemoryStorageBackend(): CredentialStorageBackend { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +} diff --git a/examples/shared/src/mock/oid4vci.ts b/examples/shared/src/mock/oid4vci.ts new file mode 100644 index 0000000..5054ae1 --- /dev/null +++ b/examples/shared/src/mock/oid4vci.ts @@ -0,0 +1,138 @@ +import { + CredentialClaimName, + CredentialConfigurationId, + CredentialFormatId, + CredentialId, + CredentialScope, + CredentialTypeName, + ISO8601DateTime, + UriString, + ProofUtils, + type CredentialOfferUri, + type CredentialIssuer, + type IOid4vciClient, + type CredentialIssuanceContext, + type CredentialOffer, + type StoredCredential, + type IssuerMetadata, +} from "@1shotapi/ows-types"; +import { + MOCK_KYC_OFFER, + MOCK_KYC_OFFER_URI, + MOCK_KYC_ISSUER_ID, + MOCK_OID4VCI_PROOF_NONCE, +} from "../fixtures.js"; +import { issueDemoSdJwtVc } from "../demo/issuer.js"; +import { DEMO_HOLDER_PUBLIC_JWK } from "../demo/demo-keys.js"; + +/** MOCK OID4VCI client — resolves mock:// URIs and issues real demo SD-JWT VCs. */ +export class MockOid4vciClient implements IOid4vciClient { + async resolveOffer(uri: CredentialOfferUri): Promise { + if (uri === MOCK_KYC_OFFER_URI || uri.startsWith("mock://kyc-offer")) { + return { ...MOCK_KYC_OFFER }; + } + throw new Error(`MockOid4vciClient: unknown offer URI: ${uri}`); + } + + async fetchIssuerMetadata(issuer: CredentialIssuer): Promise { + return { + credentialIssuer: issuer, + credentialConfigurationsSupported: { + [CredentialConfigurationId("KycCredential")]: { + format: CredentialFormatId("sd-jwt-vc"), + scope: CredentialScope("kyc"), + }, + }, + }; + } + + async requestCredential( + _offer: CredentialOffer, + metadata: IssuerMetadata, + context?: CredentialIssuanceContext, + ): Promise { + if (!context?.proof || !context.holderPublicKeyJwk) { + throw new Error( + "MockOid4vciClient: CredentialIssuanceContext with proof and holderPublicKeyJwk is required", + ); + } + + const expectedNonce = context.nonce ?? MOCK_OID4VCI_PROOF_NONCE; + const proofResult = await ProofUtils.verifyOid4vciProofJwt({ + jwt: context.proof.jwt, + expectedAudience: metadata.credentialIssuer, + expectedNonce, + }); + if (!proofResult.valid || !proofResult.holderPublicKeyJwk) { + throw new Error( + `MockOid4vciClient: OID4VCI proof rejected: ${proofResult.reasons.join("; ")}`, + ); + } + + const keyMatches = await ProofUtils.jwksEqualsByThumbprint( + proofResult.holderPublicKeyJwk, + context.holderPublicKeyJwk, + ); + if (!keyMatches) { + throw new Error( + "MockOid4vciClient: proof jwk does not match holderPublicKeyJwk", + ); + } + + const holderJwk = context.holderPublicKeyJwk; + const issuedAt = ISO8601DateTime(new Date().toISOString()); + const validUntil = ISO8601DateTime( + new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + ); + + const subject = { + ageOver18: true, + country: "US", + assuranceLevel: "substantial", + verifiedAt: issuedAt, + }; + + const payload = await issueDemoSdJwtVc({ + issuer: MOCK_KYC_ISSUER_ID, + vct: "KycCredential", + claims: subject, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + CredentialClaimName("assuranceLevel"), + CredentialClaimName("verifiedAt"), + ], + holderPublicKeyJwk: holderJwk, + }); + + return { + credentialId: CredentialId(`cred_mock_kyc_${Date.now()}`), + format: "sd-jwt-vc", + type: [ + CredentialTypeName("VerifiableCredential"), + CredentialTypeName("KycCredential"), + ], + issuer: MOCK_KYC_ISSUER_ID, + issuedAt, + validUntil, + payload, + semantic: { + type: [ + CredentialTypeName("VerifiableCredential"), + CredentialTypeName("KycCredential"), + ], + issuer: MOCK_KYC_ISSUER_ID, + validFrom: issuedAt, + validUntil, + credentialSubject: subject, + credentialSchema: { + id: UriString("https://schemas.ows.example/kyc/v1"), + type: "JsonSchema", + }, + }, + }; + } +} + +/** Public JWK for offline fixtures that still use the demo holder keypair. */ +export { DEMO_HOLDER_PUBLIC_JWK }; diff --git a/examples/shared/src/mock/oid4vp.ts b/examples/shared/src/mock/oid4vp.ts new file mode 100644 index 0000000..cfdc3ce --- /dev/null +++ b/examples/shared/src/mock/oid4vp.ts @@ -0,0 +1,66 @@ +import type { + IOid4vpClient, + PresentationBuildContext, + PresentationRequestUri, + StoredCredential, + PresentationDefinition, + PresentationResult, + CredentialSummary, +} from "@1shotapi/ows-types"; +import { PresentationUtils } from "@1shotapi/ows-types"; +import { + MOCK_KYC_PRESENTATION_REQUEST, + MOCK_KYC_PRESENTATION_URI, +} from "../fixtures.js"; +import { createDemoHolderSigner } from "../demo/jwk-holder-signer.js"; + +/** MOCK OID4VP client — resolves mock:// URIs and builds real SD-JWT VC presentations. */ +export class MockOid4vpClient implements IOid4vpClient { + async resolveRequest(uri: PresentationRequestUri): Promise { + if ( + uri === MOCK_KYC_PRESENTATION_URI || + uri.startsWith("mock://kyc-presentation") + ) { + return { ...MOCK_KYC_PRESENTATION_REQUEST }; + } + throw new Error(`MockOid4vpClient: unknown request URI: ${uri}`); + } + + async matchCredentials( + definition: PresentationDefinition, + credentials: CredentialSummary[], + ): Promise { + const types = definition.credentialTypes ?? []; + return credentials.filter((c) => { + if (types.length === 0) { + return true; + } + return types.some((t) => c.type.includes(t)); + }); + } + + async buildPresentation( + credential: StoredCredential, + definition: PresentationDefinition, + context?: PresentationBuildContext, + ): Promise { + const holderSigner = context?.holderSigner ?? createDemoHolderSigner(); + + if (credential.format === "sd-jwt-vc") { + const built = await PresentationUtils.build({ + credential, + definition, + holderSigner, + }); + return { + presentation: built.presentation, + format: credential.format, + disclosedClaims: built.disclosedClaims, + }; + } + + throw new Error( + `MockOid4vpClient: unsupported credential format ${credential.format}`, + ); + } +} diff --git a/examples/shared/test/mock-e2e.test.ts b/examples/shared/test/mock-e2e.test.ts new file mode 100644 index 0000000..fc9aabb --- /dev/null +++ b/examples/shared/test/mock-e2e.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + DemoCredentialFlow, + validateMockPresentation, + MockOid4vciClient, + MOCK_KYC_POLICY, + MOCK_KYC_ISSUER_ID, + InMemoryIssuerTrustRegistry, +} from "../src/index.js"; +import { + CredentialIssuer, + CredentialOfferUri, + CredentialTypeName, + ISO8601DateTime, + PresentationRequestUri, +} from "@1shotapi/ows-types"; + +describe("mock credential e2e flow", () => { + it("issuer offer → store → present → verify", async () => { + const flow = new DemoCredentialFlow(); + + const receipt = await flow.acceptOffer({ + credentialOfferUri: CredentialOfferUri("mock://kyc-offer/demo"), + }); + assert.ok(receipt.credentialId); + + const listed = await flow.list({ type: CredentialTypeName("KycCredential") }); + assert.equal(listed.length, 1); + + const presentation = await flow.present({ + requestUri: PresentationRequestUri("mock://kyc-presentation/demo"), + }); + + assert.ok(presentation.presentation.startsWith("eyJ")); + assert.deepEqual(presentation.disclosedClaims, ["ageOver18", "country"]); + + const verification = await validateMockPresentation( + presentation, + MOCK_KYC_POLICY, + MOCK_KYC_ISSUER_ID, + ); + assert.equal(verification.valid, true); + assert.equal(verification.reasons.length, 0); + assert.equal(verification.disclosedClaims.country, "US"); + assert.equal(verification.disclosedClaims.ageOver18, true); + assert.ok(verification.holderThumbprint); + assert.equal(verification.custody.every((step) => step.ok), true); + }); + + it("rejects issuance without OID4VCI proof", async () => { + const client = new MockOid4vciClient(); + const offer = await client.resolveOffer( + CredentialOfferUri("mock://kyc-offer/demo"), + ); + const metadata = await client.fetchIssuerMetadata(offer.credentialIssuer); + await assert.rejects( + () => client.requestCredential(offer, metadata), + /proof/, + ); + }); + + it("rejects presentation when user declines", async () => { + const flow = new DemoCredentialFlow({ + approvePresentation: async () => false, + }); + + await flow.acceptOffer(); + + await assert.rejects( + () => flow.present(), + /User rejected presentation/, + ); + }); + + it("rejects untrusted issuer before issuance", async () => { + const flow = new DemoCredentialFlow({ + trust: new InMemoryIssuerTrustRegistry([]), + }); + await assert.rejects(() => flow.acceptOffer(), /Untrusted issuer/); + }); + + it("fails closed when credential status is revoked", async () => { + const flow = new DemoCredentialFlow({ + status: { + async checkStatus(credential) { + return { + credentialId: credential.credentialId, + status: "revoked", + checkedAt: ISO8601DateTime(new Date().toISOString()), + }; + }, + }, + }); + await assert.rejects(() => flow.acceptOffer(), /revoked/); + }); + + it("filters matches by acceptedIssuers on present", async () => { + const flow = new DemoCredentialFlow(); + await flow.acceptOffer(); + await assert.rejects( + () => + flow.present({ + requestUri: PresentationRequestUri("mock://kyc-presentation/demo"), + acceptedIssuers: [ + CredentialIssuer("https://other.issuer.example"), + ], + }), + /No matching credentials/, + ); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6cae3a9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,11589 @@ +{ + "name": "open-wallet", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-wallet", + "license": "MIT", + "workspaces": [ + "packages/*", + "examples/*" + ], + "devDependencies": { + "@changesets/cli": "^2.29.0", + "@types/node": "^22.19.0", + "react-doctor": "^0.9.1", + "typescript": "~5.8.0" + }, + "engines": { + "node": ">=22" + } + }, + "examples/credential-issuer": { + "name": "@1shotapi/ows-example-credential-issuer", + "version": "0.0.0", + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*" + }, + "devDependencies": { + "dotenv": "^16.4.7", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } + }, + "examples/credential-verifier": { + "name": "@1shotapi/ows-example-credential-verifier", + "version": "0.0.0", + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*", + "jose": "^6.0.11" + }, + "devDependencies": { + "dotenv": "^16.4.7", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } + }, + "examples/general-wallet": { + "name": "@1shotapi/ows-example-general-wallet", + "version": "0.0.0", + "dependencies": { + "@1shotapi/ows-oid4": "*", + "@1shotapi/ows-signer-utils": "*", + "@1shotapi/ows-types": "*", + "@1shotapi/ows-wallet-utils": "*", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "viem": "^2.52.0" + }, + "devDependencies": { + "@ngrok/ngrok": "^1.7.0", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "dotenv": "^16.4.7", + "tailwindcss": "^4.1.11", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } + }, + "examples/host": { + "name": "@1shotapi/ows-example-host", + "version": "0.0.0", + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*", + "@wallet-standard/app": "^1.0.1", + "viem": "^2.52.0" + }, + "devDependencies": { + "dotenv": "^16.4.7", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } + }, + "examples/shared": { + "name": "@1shotapi/ows-example-shared", + "version": "0.0.0", + "dependencies": { + "@1shotapi/ows-provider": "*", + "@1shotapi/ows-types": "*", + "@sd-jwt/sd-jwt-vc": "^0.20.0" + }, + "devDependencies": { + "tsx": "^4.19.0" + } + }, + "node_modules/@1shotapi/ows-example-credential-issuer": { + "resolved": "examples/credential-issuer", + "link": true + }, + "node_modules/@1shotapi/ows-example-credential-verifier": { + "resolved": "examples/credential-verifier", + "link": true + }, + "node_modules/@1shotapi/ows-example-general-wallet": { + "resolved": "examples/general-wallet", + "link": true + }, + "node_modules/@1shotapi/ows-example-host": { + "resolved": "examples/host", + "link": true + }, + "node_modules/@1shotapi/ows-example-shared": { + "resolved": "examples/shared", + "link": true + }, + "node_modules/@1shotapi/ows-oid4": { + "resolved": "packages/ows-oid4", + "link": true + }, + "node_modules/@1shotapi/ows-onchain": { + "resolved": "packages/ows-onchain", + "link": true + }, + "node_modules/@1shotapi/ows-provider": { + "resolved": "packages/ows-provider", + "link": true + }, + "node_modules/@1shotapi/ows-signer": { + "resolved": "packages/ows-signer", + "link": true + }, + "node_modules/@1shotapi/ows-signer-utils": { + "resolved": "packages/ows-signer-utils", + "link": true + }, + "node_modules/@1shotapi/ows-types": { + "resolved": "packages/ows-types", + "link": true + }, + "node_modules/@1shotapi/ows-wallet-utils": { + "resolved": "packages/ows-wallet-utils", + "link": true + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz", + "integrity": "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.2.tgz", + "integrity": "sha512-GvpKWDmzBFzbtVElU+tEDMDYyMY8SnMH4NNwwlUQioNHZvE6sSuHEqNkfU3iYhWL+/XO5Ej+MzhVJoqM10NYCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz", + "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@changesets/apply-release-plan": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", + "integrity": "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/config": "^3.1.4", + "@changesets/get-version-range-type": "^0.4.0", + "@changesets/git": "^3.0.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "detect-indent": "^6.0.0", + "fs-extra": "^7.0.1", + "lodash.startcase": "^4.4.0", + "outdent": "^0.5.0", + "prettier": "^2.7.1", + "resolve-from": "^5.0.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/assemble-release-plan": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", + "integrity": "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/changelog-git": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", + "integrity": "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0" + } + }, + "node_modules/@changesets/cli": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.31.0.tgz", + "integrity": "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/apply-release-plan": "^7.1.1", + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/changelog-git": "^0.2.1", + "@changesets/config": "^3.1.4", + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/get-release-plan": "^4.0.16", + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@changesets/write": "^0.4.0", + "@inquirer/external-editor": "^1.0.2", + "@manypkg/get-packages": "^1.1.3", + "ansi-colors": "^4.1.3", + "enquirer": "^2.4.1", + "fs-extra": "^7.0.1", + "mri": "^1.2.0", + "package-manager-detector": "^0.2.0", + "picocolors": "^1.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "spawndamnit": "^3.0.1", + "term-size": "^2.1.0" + }, + "bin": { + "changeset": "bin.js" + } + }, + "node_modules/@changesets/config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", + "integrity": "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/logger": "^0.1.1", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1", + "micromatch": "^4.0.8" + } + }, + "node_modules/@changesets/errors": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", + "integrity": "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==", + "dev": true, + "license": "MIT", + "dependencies": { + "extendable-error": "^0.1.5" + } + }, + "node_modules/@changesets/get-dependents-graph": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.4.tgz", + "integrity": "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "picocolors": "^1.1.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/get-release-plan": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.16.tgz", + "integrity": "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/config": "^3.1.4", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/get-version-range-type": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz", + "integrity": "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/git": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz", + "integrity": "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@manypkg/get-packages": "^1.1.3", + "is-subdir": "^1.1.1", + "micromatch": "^4.0.8", + "spawndamnit": "^3.0.1" + } + }, + "node_modules/@changesets/logger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz", + "integrity": "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/parse": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-0.4.3.tgz", + "integrity": "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "js-yaml": "^4.1.1" + } + }, + "node_modules/@changesets/pre": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz", + "integrity": "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1" + } + }, + "node_modules/@changesets/read": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.7.tgz", + "integrity": "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/parse": "^0.4.3", + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "p-filter": "^2.1.0", + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/should-skip-package": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", + "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/types": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", + "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/write": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", + "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "human-id": "^4.1.1", + "prettier": "^2.7.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@ethersproject/abi": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", + "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@manypkg/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@types/node": "^12.7.1", + "find-up": "^4.1.0", + "fs-extra": "^8.1.0" + } + }, + "node_modules/@manypkg/find-root/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/find-root/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@manypkg/get-packages": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", + "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@changesets/types": "^4.0.1", + "@manypkg/find-root": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "^11.0.0", + "read-yaml-file": "^1.1.0" + } + }, + "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", + "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/get-packages/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@ngrok/ngrok": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz", + "integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.7.0", + "@ngrok/ngrok-darwin-arm64": "1.7.0", + "@ngrok/ngrok-darwin-universal": "1.7.0", + "@ngrok/ngrok-darwin-x64": "1.7.0", + "@ngrok/ngrok-freebsd-x64": "1.7.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", + "@ngrok/ngrok-linux-arm64-musl": "1.7.0", + "@ngrok/ngrok-linux-x64-gnu": "1.7.0", + "@ngrok/ngrok-linux-x64-musl": "1.7.0", + "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", + "@ngrok/ngrok-win32-x64-msvc": "1.7.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz", + "integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz", + "integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz", + "integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz", + "integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz", + "integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz", + "integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz", + "integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz", + "integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz", + "integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz", + "integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz", + "integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz", + "integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz", + "integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", + "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nomicfoundation/edr": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.12.0.tgz", + "integrity": "sha512-dVfrB70L//W05s+s+/c6n52Fct+kVKoXYT+/CKL9ZsNdq/yLr5LaPNsvpVkQHP1JdAiOrubUzA/MwZIk4gRxAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nomicfoundation/edr-darwin-arm64": "0.12.0", + "@nomicfoundation/edr-darwin-x64": "0.12.0", + "@nomicfoundation/edr-linux-arm64-gnu": "0.12.0", + "@nomicfoundation/edr-linux-arm64-musl": "0.12.0", + "@nomicfoundation/edr-linux-x64-gnu": "0.12.0", + "@nomicfoundation/edr-linux-x64-musl": "0.12.0", + "@nomicfoundation/edr-win32-x64-msvc": "0.12.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-darwin-arm64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.12.0.tgz", + "integrity": "sha512-z/8jU2dgZjhY2iLtJ1DGi3t/N2xbmjgok9K3R0f7+UZxSSJ5LbXCFn5So33fVh47RzGzOqEB+Yk4SdyUq2odqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-darwin-x64": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.12.0.tgz", + "integrity": "sha512-F9RrA60mEtxfKFiGB7QsydoUwzz4ckoustVMFegcIKmjRxRVb1qrRYiAc9oQiKMdJWIZKDYsOpHABJ9Um4U/+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-arm64-gnu": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.12.0.tgz", + "integrity": "sha512-EtGRZbh1d4BF/1SIG5rVrKQ9R0nuNvPgCYiU5fCmY3bojAFOUf4m7I2ezIhim1vb1QBlXmFaoFNPZFIdMltBGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-arm64-musl": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.12.0.tgz", + "integrity": "sha512-5gWtmKuVfftcO1OUbF/3KTPVSR6klC7RI9Z96G5lDO325jQhYqOG+hkvDPKtM+nbYf+A0veOndghqbUgAX+E4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-x64-gnu": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.12.0.tgz", + "integrity": "sha512-0Ty0fov3/NFL0dIshNTGCi06sjVzsgB7k+n9LAoj+57OsqP9X4e3P5XwjlTSNuyYshv8JdYMHqY+1ZIZ8SHsyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-linux-x64-musl": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.12.0.tgz", + "integrity": "sha512-e5du3t17vdB3tAY0kl3Ip1cu8CcwiaeJeUC4mMPmL91HDM//AQehqpyIP8upam4AeIl1H6FA3xIUIDA6VOZSxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/edr-win32-x64-msvc": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.12.0.tgz", + "integrity": "sha512-wD8YxhFdlY2IXb7uVrSF7VHartFKXEYeiE6ISJOV10Y7YOUE1IzfwB0QOdKtLUepwB+HXis+KRy4h22dWEbT0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@nomicfoundation/hardhat-errors": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-errors/-/hardhat-errors-3.0.15.tgz", + "integrity": "sha512-h3r32RzpmWEcB2bz6aqZKlKOP8tzyvHLkPFleFFqwVvjO5AUfSoMUxm8OjaTuNgPF35mXPYRISh+kNCwmsKVOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nomicfoundation/hardhat-utils": "^4.1.3" + } + }, + "node_modules/@nomicfoundation/hardhat-ignition": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-ignition/-/hardhat-ignition-3.1.7.tgz", + "integrity": "sha512-VaJpdKzVTKhEWOb9lQdXQcghlF/H1kasyfeOw21i69NaZ49QmFc2kRLhnTcJA+vLj+jV1fYk29fK0NO1Jsnavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.2", + "@nomicfoundation/ignition-core": "^3.1.7", + "@nomicfoundation/ignition-ui": "^3.1.2", + "json5": "^2.2.3", + "prompts": "^2.4.2" + }, + "peerDependencies": { + "@nomicfoundation/hardhat-verify": "^3.0.0", + "hardhat": "^3.8.0" + } + }, + "node_modules/@nomicfoundation/hardhat-ignition-viem": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-ignition-viem/-/hardhat-ignition-viem-3.1.6.tgz", + "integrity": "sha512-ggjHftjDNLQPnHBMEtDcTafYJfTzqTMlorKlAWj0HunzwiDhC5p7ox2MuEGmZHqt2oVjrnPF5E+Dp3G0RlYRgg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.15" + }, + "peerDependencies": { + "@nomicfoundation/hardhat-ignition": "^3.1.2", + "@nomicfoundation/hardhat-verify": "^3.0.0", + "@nomicfoundation/hardhat-viem": "^3.0.4", + "@nomicfoundation/ignition-core": "^3.0.7", + "hardhat": "^3.8.0", + "viem": "^2.47.6" + } + }, + "node_modules/@nomicfoundation/hardhat-keystore": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-keystore/-/hardhat-keystore-3.0.12.tgz", + "integrity": "sha512-uXySRPOOHtAg/RrwmqiQyw0QDA6Er3H6rB4su0J59mBPC/QHw9B5F9dDfF2u+FZn5LVWH6Gh58mEvJhRt0a4dQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/ciphers": "1.2.1", + "@noble/hashes": "1.7.1", + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.3", + "@nomicfoundation/hardhat-zod-utils": "^3.0.5", + "zod": "^3.23.8" + }, + "peerDependencies": { + "hardhat": "^3.8.0" + } + }, + "node_modules/@nomicfoundation/hardhat-network-helpers": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-network-helpers/-/hardhat-network-helpers-3.0.10.tgz", + "integrity": "sha512-zSw6vHOQYP5FOgbM1pvOerO6/022HFQUfzGCAb+CFu5iDq2Skpc1BI0DASOKBTTklwuXMHzDmsCJFHF07c9nuA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.2" + }, + "peerDependencies": { + "hardhat": "^3.8.0" + } + }, + "node_modules/@nomicfoundation/hardhat-node-test-reporter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-node-test-reporter/-/hardhat-node-test-reporter-3.1.0.tgz", + "integrity": "sha512-mcVaJyqQnryVOv/fWmo0IofVfRYvcoLyzR+D2LiLLtxvmmMTYLYW5ktPk0XHuJ+UBK735aZTrMTDUR3S/x9Edw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@actions/core": "^1.10.1", + "jest-diff": "^29.7.0" + } + }, + "node_modules/@nomicfoundation/hardhat-node-test-runner": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-node-test-runner/-/hardhat-node-test-runner-3.0.17.tgz", + "integrity": "sha512-zn+5BJ00n9pCFIAKJDegf65P7AM8+tcgCsrtXkjVH7wA46jssGaKQ8VduTwoekfZn8DdmwPTL7MWXqwjSS/Lkg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-node-test-reporter": "^3.1.0", + "@nomicfoundation/hardhat-utils": "^4.1.2", + "@nomicfoundation/hardhat-zod-utils": "^3.0.5", + "tsx": "^4.19.3", + "zod": "^3.23.8" + }, + "peerDependencies": { + "hardhat": "^3.8.0" + } + }, + "node_modules/@nomicfoundation/hardhat-toolbox-viem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-toolbox-viem/-/hardhat-toolbox-viem-5.0.7.tgz", + "integrity": "sha512-aHF77tTYmBIbuxY99KKBPx4cLWdcQvvar1PpzWJyrDgC+P6rA7Edi1GpvvPcwO/HdfZXuACQ74zSS2PnmJu3mA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@nomicfoundation/hardhat-ignition": "^3.0.7", + "@nomicfoundation/hardhat-ignition-viem": "^3.0.7", + "@nomicfoundation/hardhat-keystore": "^3.0.0", + "@nomicfoundation/hardhat-network-helpers": "^3.0.0", + "@nomicfoundation/hardhat-node-test-runner": "^3.0.0", + "@nomicfoundation/hardhat-verify": "^3.0.0", + "@nomicfoundation/hardhat-viem": "^3.0.4", + "@nomicfoundation/hardhat-viem-assertions": "^3.0.5", + "@nomicfoundation/ignition-core": "^3.0.7", + "hardhat": "^3.8.0", + "viem": "^2.47.6" + } + }, + "node_modules/@nomicfoundation/hardhat-utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-utils/-/hardhat-utils-4.1.3.tgz", + "integrity": "sha512-SYDKX6SCdzs/5mC/S1D+AjNSQJrhxevHTI24TIouIjO0Xs0dB0+S4cYCqHWOgvnHLshrcrJ7XGUjS7+apWa1Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@streamparser/json-node": "^0.0.22", + "env-paths": "^2.2.0", + "ethereum-cryptography": "^2.2.1", + "fast-equals": "^5.4.0", + "json-stream-stringify": "^3.1.6", + "rfdc": "^1.3.1", + "undici": "^6.16.1" + } + }, + "node_modules/@nomicfoundation/hardhat-utils/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@nomicfoundation/hardhat-vendored": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-vendored/-/hardhat-vendored-3.0.4.tgz", + "integrity": "sha512-RO8Otj1FvRvxJmXzkxh1vTwK/+cqSVPYLqY6RrWkmzHEEcxnAwAFsBYdW7xyTEyW/pVbSSNd2gs3aoGdGZaoNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nomicfoundation/hardhat-verify": { + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-verify/-/hardhat-verify-3.0.20.tgz", + "integrity": "sha512-chMhQpnE8FWv5QKmbD9Mn6HvBLuaxkUQ6LlZw4STyubsaXb4pemRh+e5Aqbb07BbMTm7inPUZvUEzmdhGUvE7w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@ethersproject/abi": "^5.8.0", + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.2", + "@nomicfoundation/hardhat-zod-utils": "^3.0.5", + "cbor2": "^1.9.0", + "zod": "^3.23.8" + }, + "peerDependencies": { + "hardhat": "^3.8.0" + } + }, + "node_modules/@nomicfoundation/hardhat-viem": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-viem/-/hardhat-viem-3.0.9.tgz", + "integrity": "sha512-GtQ7l55C70Jj80yrZGdW7Kah0vPmEje29G/xVfcxchVxRRFdj1XyFZFn+7e53b11qhHMWDB787pVN+I8YZae3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.2" + }, + "peerDependencies": { + "hardhat": "^3.8.0", + "viem": "^2.47.6" + } + }, + "node_modules/@nomicfoundation/hardhat-viem-assertions": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-viem-assertions/-/hardhat-viem-assertions-3.1.1.tgz", + "integrity": "sha512-FHoG4U0HmJkBPoGgC22QKGM/3BxcyBpYGpnUzlwF1vA7Q1XPyWpiDNUHUkiUZoVESww9aoG+1Phk09TDCXa7Pg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.3" + }, + "peerDependencies": { + "@nomicfoundation/hardhat-viem": "^3.0.4", + "hardhat": "^3.8.0", + "viem": "^2.47.6" + } + }, + "node_modules/@nomicfoundation/hardhat-zod-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-zod-utils/-/hardhat-zod-utils-3.0.5.tgz", + "integrity": "sha512-A1G9Jcizf/vYcGMtqkf+st94zBPTDB+bXXlojOMu77gmBZYbywY0k7hdRM2B4uJY+8nM0oe0sNVGVkARITXdcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nomicfoundation/hardhat-errors": "^3.0.13", + "@nomicfoundation/hardhat-utils": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@nomicfoundation/ignition-core": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@nomicfoundation/ignition-core/-/ignition-core-3.1.7.tgz", + "integrity": "sha512-3u9dZ3+tRW8uSNTMHlYONNKZ2psHjmMdUwtHKHOLsaWdyGBSNMwMnGmLCpXlqhPm+p40cmMSruyzogAQkcEseA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ethersproject/address": "5.6.1", + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.2", + "@nomicfoundation/solidity-analyzer": "^0.1.1", + "cbor2": "^1.9.0", + "ethers": "^6.14.0", + "immer": "10.0.2", + "lodash-es": "4.17.21", + "ndjson": "2.0.0" + } + }, + "node_modules/@nomicfoundation/ignition-core/node_modules/@ethersproject/address": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.6.1.tgz", + "integrity": "sha512-uOgF0kS5MJv9ZvCz7x6T2EXJSzotiybApn4XlOgoTX0xdtyVIJ7pF+6cGPxiEq/dpBiTfMiw7Yc81JcwhSYA0Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.6.2", + "@ethersproject/bytes": "^5.6.1", + "@ethersproject/keccak256": "^5.6.1", + "@ethersproject/logger": "^5.6.0", + "@ethersproject/rlp": "^5.6.1" + } + }, + "node_modules/@nomicfoundation/ignition-ui": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/ignition-ui/-/ignition-ui-3.1.2.tgz", + "integrity": "sha512-OoS5eQi9WBeiYI6EXurhqrpr6syRVhnaUzdx5fyK/1syKGq9BsjWWHXTNru0qk5ZFQ9f/KMTZotcDZD4eAdCpg==", + "dev": true + }, + "node_modules/@nomicfoundation/solidity-analyzer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer/-/solidity-analyzer-0.1.2.tgz", + "integrity": "sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + }, + "optionalDependencies": { + "@nomicfoundation/solidity-analyzer-darwin-arm64": "0.1.2", + "@nomicfoundation/solidity-analyzer-darwin-x64": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-arm64-gnu": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-arm64-musl": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-x64-gnu": "0.1.2", + "@nomicfoundation/solidity-analyzer-linux-x64-musl": "0.1.2", + "@nomicfoundation/solidity-analyzer-win32-x64-msvc": "0.1.2" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-darwin-arm64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.2.tgz", + "integrity": "sha512-JaqcWPDZENCvm++lFFGjrDd8mxtf+CtLd2MiXvMNTBD33dContTZ9TWETwNFwg7JTJT5Q9HEecH7FA+HTSsIUw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-darwin-x64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-darwin-x64/-/solidity-analyzer-darwin-x64-0.1.2.tgz", + "integrity": "sha512-fZNmVztrSXC03e9RONBT+CiksSeYcxI1wlzqyr0L7hsQlK1fzV+f04g2JtQ1c/Fe74ZwdV6aQBdd6Uwl1052sw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-arm64-gnu": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-arm64-gnu/-/solidity-analyzer-linux-arm64-gnu-0.1.2.tgz", + "integrity": "sha512-3d54oc+9ZVBuB6nbp8wHylk4xh0N0Gc+bk+/uJae+rUgbOBwQSfuGIbAZt1wBXs5REkSmynEGcqx6DutoK0tPA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-arm64-musl": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-arm64-musl/-/solidity-analyzer-linux-arm64-musl-0.1.2.tgz", + "integrity": "sha512-iDJfR2qf55vgsg7BtJa7iPiFAsYf2d0Tv/0B+vhtnI16+wfQeTbP7teookbGvAo0eJo7aLLm0xfS/GTkvHIucA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-x64-gnu": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-x64-gnu/-/solidity-analyzer-linux-x64-gnu-0.1.2.tgz", + "integrity": "sha512-9dlHMAt5/2cpWyuJ9fQNOUXFB/vgSFORg1jpjX1Mh9hJ/MfZXlDdHQ+DpFCs32Zk5pxRBb07yGvSHk9/fezL+g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-linux-x64-musl": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-linux-x64-musl/-/solidity-analyzer-linux-x64-musl-0.1.2.tgz", + "integrity": "sha512-GzzVeeJob3lfrSlDKQw2bRJ8rBf6mEYaWY+gW0JnTDHINA0s2gPR4km5RLIj1xeZZOYz4zRw+AEeYgLRqB2NXg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@nomicfoundation/solidity-analyzer-win32-x64-msvc": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/solidity-analyzer-win32-x64-msvc/-/solidity-analyzer-win32-x64-msvc-0.1.2.tgz", + "integrity": "sha512-Fdjli4DCcFHb4Zgsz0uEJXZ2K7VEO+w5KVv7HmT7WO10iODdU9csC2az4jrhEsRtiR9Gfd74FlG0NYlw1BMdyA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@owf/identity-common": { + "version": "0.1.0-alpha-20260422101556", + "resolved": "https://registry.npmjs.org/@owf/identity-common/-/identity-common-0.1.0-alpha-20260422101556.tgz", + "integrity": "sha512-0CJhMHXWf+Dn0MpZCeachjFjMjBI2uCJl56Po3dnGiJDis5wVkfWPeCdPJZ2h4fBWwXV0+Owa8nJRxXeruRwrA==", + "license": "Apache-2.0" + }, + "node_modules/@owf/token-status-list": { + "version": "0.1.0-alpha-20260422101556", + "resolved": "https://registry.npmjs.org/@owf/token-status-list/-/token-status-list-0.1.0-alpha-20260422101556.tgz", + "integrity": "sha512-cwZHQPqBaXt3pTbllI6GLafE+Ws8HfEYoOgmpiQiK1sFElkF07mFZt1FTt1YElbMWtEV6YLTh+MjEWdJdRNklA==", + "license": "Apache-2.0", + "dependencies": { + "@owf/identity-common": "0.1.0-alpha-20260422101556", + "cbor-x": "^1.6.4", + "pako": "^2.1.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.141.0.tgz", + "integrity": "sha512-jk7086MFvR/T4DG9IY7MKBVt1PMxvSZoz/TvnifodvS0pjghVwJHRttnAExhlwdMOgHv1TmLdENnbNpYk2zjvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.141.0.tgz", + "integrity": "sha512-a4XDQ27ZT7e7zwAlxJDTiCA7IBGWDuy2+MhFq85Of7XlBSmpkfcBFml11q0Zx6f7RMuI0B4xCtt2ytBS4yOptg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.141.0.tgz", + "integrity": "sha512-m/kVk6rzYmBeHYnz+1Y5fod00AVTTxMbC71azFfm/zjx1j9XxwKtA0+VfkKuVMC8rbghb9TtfevnuWZa9OuPEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.141.0.tgz", + "integrity": "sha512-o0X+6KZlfucWU/v5oKRQPwdFXsXAjW8jmpo/Gpw/qyKsbKtlfkHoeH9Bjp/m13TwjewvJnCkwF0DWzgpC4HjTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.141.0.tgz", + "integrity": "sha512-W5KbTnNkTMMMylqj6dYqnsXvkmESVPodPKYLJ5zdzIPdl9fUJtolkpUeSzYEbGGYB4a4A4avl3EePnZ/wLIdJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.141.0.tgz", + "integrity": "sha512-g3dtbJa8zeOGK36Sr9cQavsdi5H/ie2hVjrSjIxsNAR1qZA40ZYVXnfdfoMAlq8CmB9qFL1yhsSCUHeNmdmt8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.141.0.tgz", + "integrity": "sha512-e6hwQqd+3lvP13G2jxvFpoA7dzHcFLN+Mq47JCVMtdNHbbyBRo756JCtbbJH6ca8inTfyqZoqBmS3vhQlzAK2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.141.0.tgz", + "integrity": "sha512-vXz2BLAuypA+4MLyBg94pzEo6THVnzYnCtAjXoihIIQo0t2pnp/AmW+SH1EI+4VbuJnC//KplIJ5yyaCGua4jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.141.0.tgz", + "integrity": "sha512-jMkS/EztNW34HKsXIaT/SoHcmtocq/vWhwFOVduF9kduuuRIVwfwQ6uxzIO+qPKSXdd2TXt54of0BJ2zFMXnmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.141.0.tgz", + "integrity": "sha512-vo+MR+n3zQJ6Mq92hiP084NZcgDv5iJlVR02gMf28neMvVT1tKVm7VeiW/DxhdqOi3QLeaXIk9cUcLL1qrkngw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.141.0.tgz", + "integrity": "sha512-oh80w+7RuiO5gBp9Jnoa/H8Qlt3JsHL2MkW+0dwEdlDMdslVZX/YsekSK6EeyEenY66/mhCfypsNATQ7Ph3qlQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.141.0.tgz", + "integrity": "sha512-LOyEmFA8sCnYbEXP1+iQvCC/P1YXHMA/t6x1Ksp0Y9VwhLFsiBJFzV1zIxrOIE2LKaGGhDjQ29xq9cbq6omDXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.141.0.tgz", + "integrity": "sha512-3wnwk/l1CvszVE5TJR1wSl/zSEfydRqrNhn6s7Vr9IzSJpUQIroqVsIoPARHRFA+FQwkxAFDAHDAasa7v8OobQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.141.0.tgz", + "integrity": "sha512-qtyQVAAebFq57B2tifTlel3TgGqUtsYNI/e+p6aya9rN9lOZVTDvr215fGYSA9XWooxzMxDiVxkBLk2jQHbsOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.141.0.tgz", + "integrity": "sha512-SkGV1nKw40roEc94pv5EaaeH2ay14G6+roe8Q0wIUC1LcEKxzKW921h7+ZuZX0D3q2Mb/7aSFmxEVqnko3lPRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.141.0.tgz", + "integrity": "sha512-cVgDM7n8QziQqOaP5hNgUYfMG7S/ZeuPxFWXnnHRv7rh025COk0rfQ6eEdKG3j/GaUuyvNZN4ifF1J8KmuXLLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.141.0.tgz", + "integrity": "sha512-HggH++Fkn3OilBn+bs3jpgIFQa34oMAyUUHy0vpGum+gt1Eb5nyLc8dNU/RAPSw6lsLrx7ncKtHSZE+3Sp0l2g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.141.0.tgz", + "integrity": "sha512-KLSEH9GwgbrqbJOjtGHt9STw96s+78yDzp7IDN8Lno+7Ut9sNBfZ4jYZIz4mD50qmWUjoOI7i9I6UENbhNbMZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.141.0.tgz", + "integrity": "sha512-9UVWUOOCI/1YkiSSNjg2zyBJYM9E/t1A/8GNobd48JDn/fQ6mzxcVO3H08jb3rAaW/B1VBf8eCORTvSsO9T08g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.141.0.tgz", + "integrity": "sha512-HI/wsvbWT5RHHw5c37D0fEgeTd8/1Q4OJs5jUmEBc17VZFG6SsCIe4barq7NsAPPks/JW+3ayi3Rp+PQI5h4Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.141.0.tgz", + "integrity": "sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.74.0.tgz", + "integrity": "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.74.0.tgz", + "integrity": "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.74.0.tgz", + "integrity": "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.74.0.tgz", + "integrity": "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.74.0.tgz", + "integrity": "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.74.0.tgz", + "integrity": "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.74.0.tgz", + "integrity": "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.74.0.tgz", + "integrity": "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.74.0.tgz", + "integrity": "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.74.0.tgz", + "integrity": "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.74.0.tgz", + "integrity": "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.74.0.tgz", + "integrity": "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.74.0.tgz", + "integrity": "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.74.0.tgz", + "integrity": "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.74.0.tgz", + "integrity": "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.74.0.tgz", + "integrity": "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.74.0.tgz", + "integrity": "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.74.0.tgz", + "integrity": "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.74.0.tgz", + "integrity": "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", + "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.4.0", + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", + "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.4.0", + "@scure/base": "~1.1.6" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.4.1.tgz", + "integrity": "sha512-ch6h66o9nPhEoe/KciMMUJeH2MBPB8UAJI1if35Fxl6885lKTsc9YoCRqTW0/RjI0Vv9GSg1tmKtHHODyAw81g==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0", + "micro-packed": "0.11.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/micro-packed": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.11.1.tgz", + "integrity": "sha512-agQGBnBCw8OoKQZMUxcaQPaOowdfxyEmBsq4CNAXAXCyB8KxlRG0lTl5GADJIsMEDaFb0trhXBHKYsjTWa5kuw==", + "license": "MIT", + "dependencies": { + "@scure/base": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sd-jwt/core": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@sd-jwt/core/-/core-0.20.0.tgz", + "integrity": "sha512-thTj5xtKqOvqnULELJGvCa64Hf+Hf7v0Dlao6mTh198rmZcyFCBop3vT1ShgWMhg5cL04ofXeiEhnQR10/8wjA==", + "license": "Apache-2.0", + "dependencies": { + "@owf/identity-common": "^0.1.0-alpha-20260312123226" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sd-jwt/sd-jwt-vc": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@sd-jwt/sd-jwt-vc/-/sd-jwt-vc-0.20.0.tgz", + "integrity": "sha512-V+pwRt4nRPDPnrHPRCbZubM3jL1Yn2DOT/AM/N/AawbFLWxsIf2HM7ioTrhpXRFC2kVFnWFmgNcq4mXXAzOBCA==", + "license": "Apache-2.0", + "dependencies": { + "@owf/token-status-list": "^0.1.0-alpha-20260312123226", + "@sd-jwt/core": "0.20.0", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sd-jwt/sd-jwt-vc/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "9.47.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", + "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.67.0.tgz", + "integrity": "sha512-SFKpZGqOCEFSmP93NdDP6ikZp4NS7A/JR8+2ofK3jF6Y9Vyox7pX0pxdOnPLpFcPtMybMahfWqSAWJvsFs4RmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.67.0", + "@sentry/node-core": "10.67.0", + "@sentry/opentelemetry": "10.67.0", + "@sentry/server-utils": "10.67.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.67.0.tgz", + "integrity": "sha512-dBHHRwZyan1pOnFJ+sNBvR8TkXbZAfZU/jpxmALS3JZ2/8AGR7cQKL+b7SleKuJ7iUDZyklN3Nqi0i5JkcA+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.67.0", + "@sentry/opentelemetry": "10.67.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/node-core/node_modules/@sentry/core": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.67.0.tgz", + "integrity": "sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node/node_modules/@sentry/core": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.67.0.tgz", + "integrity": "sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.67.0.tgz", + "integrity": "sha512-oLTOrAK1rOqmYRktOJZwz37B1seXPx1W2FTMVtzTVNjMFA/LZwGzePeZzhUOgzZgfLHixMd/ceWtGqoxAndcjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.67.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/opentelemetry/node_modules/@sentry/core": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.67.0.tgz", + "integrity": "sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.67.0.tgz", + "integrity": "sha512-GQ9t+RSTx5s3b/aZrLFuL4nrwPLMah5NiZk5cjxJmgmOSgm3nMdO8gdqCedDch5B13F3YsxtaQYjSJnzLz8M5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", + "@apm-js-collab/tracing-hooks": "^0.13.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.67.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/server-utils/node_modules/@sentry/core": { + "version": "10.67.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.67.0.tgz", + "integrity": "sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@solana/accounts": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-8.5.0.tgz", + "integrity": "sha512-vwwXYCobg1oG4kFeUEsGiNORjPujI2ff3yKLp6lCa7QBIlKaD7kZ2QRI3PZFYcNrMJ6HYNwNeZXR7ixg02pWdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/rpc-spec": "8.5.0", + "@solana/rpc-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/addresses": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/addresses/-/addresses-8.5.0.tgz", + "integrity": "sha512-LwTrDbDSJBng9y0vwiKqb/kSS9daspTGrSYoZPafeMWpAlpmutdD1KXPDHVTa4Xz00hcAfOJXYxRXX5VAoiOag==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/nominal-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/assertions": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/assertions/-/assertions-8.5.0.tgz", + "integrity": "sha512-EZDY5rJIV8Mv9xYJ7P+jvE5pihG7pmKZ6pTqbunba/4uZSQN4Klw0FX54LDDklfDvhzmAdXN0nYAnjovTGAPEg==", + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-8.5.0.tgz", + "integrity": "sha512-wALOoEuL7HAaahZnrhxCwYz10p23hku/LPNfXjlvwgjVYJL08PH3uyWDFO8e9zhcNLOaR13IREE9z8zPUhJjDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/codecs-data-structures": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/fixed-points": "8.5.0", + "@solana/options": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-core": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-8.5.0.tgz", + "integrity": "sha512-ou38qNrf9XOzCWsu/I4sTzRmnrKqhblwMVvpXwXPKAAUZwkSAKQXonwgZFY68I5HK6nkfq1NLWfZFmTEIZYLiw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-8.5.0.tgz", + "integrity": "sha512-CnnNriyltkgxZAKgAJoVRsRnrtdW4KCX37BUoIsquCM+AgSvhSbxaZNz0iaHjGOX7o1Z4s6+j7nAUgad0N/w5A==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-8.5.0.tgz", + "integrity": "sha512-6Vtf52rHzjf98ykejn9bNGMaQ2k1f4UMIa+BGMbBy4mIRQ5ggdI5oCAb3sZPfJcel2JQHwDlsLHCYdpqC+n6Mg==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/codecs-strings": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-8.5.0.tgz", + "integrity": "sha512-9lCSCUVkbPLb1o9PLDsaOatdVgrUqqWMrAqJvZPyyuvrC5++wPuKRIGq+5iU3lICY+AwWxrzVyiwdu4xaZVCiw==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "fastestsmallesttextencoderdecoder": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-8.5.0.tgz", + "integrity": "sha512-y/XTiwAZsm2kHoEDJ3ac3NUq/0E4uCbOnBe2m5mmBXF3TGmF562tDKgN5pQ+thiRABvi7b2LqwTBCg4kAdtgRg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "commander": "15.0.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/errors/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@solana/errors/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@solana/fast-stable-stringify": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/fast-stable-stringify/-/fast-stable-stringify-8.5.0.tgz", + "integrity": "sha512-CTIoOwJ0NScrB8KTzr/V4PJrNHrBAz1zvamM0opgDPQZGzLrBhca1e4eQcGmF6o7Dv3Ve0HdvDJx0B2uG6YxVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/fixed-points": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/fixed-points/-/fixed-points-8.5.0.tgz", + "integrity": "sha512-hkB7dUAD/oxXeNOvdMXVqfKZAvOJ1o50gBwzh8yLP1ihdSxVgssb36Vg7806C5HzmIxmTKZclJJokG0cRUXVSw==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/functional": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/functional/-/functional-8.5.0.tgz", + "integrity": "sha512-+8pgF1rVW78FfT0d+ceADFLKpSh/8/o9Ldx5tEkI6paiv0QeClL1rlU5nFs5tjoFkf+92x8PpiLEEGkm0CifOQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instruction-plans": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/instruction-plans/-/instruction-plans-8.5.0.tgz", + "integrity": "sha512-cES8TzRGuJ5cdVn3hr6GskwwG68rLd8R0FqmzB9NeU/nzPnW/50nRrg36DYX1Nh+OwuU/ehg6zkNNu1Vo72lKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/promises": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/instructions": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/instructions/-/instructions-8.5.0.tgz", + "integrity": "sha512-LSMOQRicz8OalMM2DT60PtAaPjvkHPhIvmPEI8KpyWOuNRwABJBRAfn5ypcdSa69VVj4IdOY10tDWZzjN4dviA==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/keys": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/keys/-/keys-8.5.0.tgz", + "integrity": "sha512-mq6amguPYLklpAOC9A+boXQhpcHGjcn4QN40J0l1TvUkfWyFJ4HBbNb35fyWldqqDUV5ujOpaPZmmVvWJBK2Kw==", + "license": "MIT", + "dependencies": { + "@solana/assertions": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/nominal-types": "8.5.0", + "@solana/promises": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/kit": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-8.5.0.tgz", + "integrity": "sha512-bMnXXADeyZFe3XyqzPhlQAIsmPYRiojaRO4JQxwGOmMwiQ5f13ZQodmsOqnt8W/n40P7r/+/pDqWuqG0TUv+yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.5.0", + "@solana/addresses": "8.5.0", + "@solana/codecs": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/instruction-plans": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/offchain-messages": "8.5.0", + "@solana/plugin-core": "8.5.0", + "@solana/plugin-interfaces": "8.5.0", + "@solana/program-client-core": "8.5.0", + "@solana/programs": "8.5.0", + "@solana/promises": "8.5.0", + "@solana/rpc": "8.5.0", + "@solana/rpc-api": "8.5.0", + "@solana/rpc-parsed-types": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "@solana/rpc-subscriptions": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/signers": "8.5.0", + "@solana/subscribable": "8.5.0", + "@solana/sysvars": "8.5.0", + "@solana/transaction-confirmation": "8.5.0", + "@solana/transaction-introspection": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/nominal-types": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-8.5.0.tgz", + "integrity": "sha512-OfTZ+w/C59Zkp51RJ3dzn2GXUojf9p/PMmSrOpxrj+hci3bdh4e6XDGY+mvP8HGPH+m0nfKWTBCygEslfoX6bg==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/offchain-messages": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/offchain-messages/-/offchain-messages-8.5.0.tgz", + "integrity": "sha512-C7qBBD1Gfbdv99uoQnuc8XZYwttTQJOYEG2v6z7q3mAL03PN7zdItAlBa+M2tpeRKjTJObRYnUUJDfiIyyGD/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-data-structures": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/nominal-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/options": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-8.5.0.tgz", + "integrity": "sha512-Ron9FrWDkwxlmuI6D+NmrZZRngC1MUncglgyN77ThAoxDE1gGMOB88mQvEILeeqONeTA1rDg1uBvbewQcILp+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "8.5.0", + "@solana/codecs-data-structures": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-core": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-core/-/plugin-core-8.5.0.tgz", + "integrity": "sha512-1zmh+UC47yxsZHleDpH4AEVkm9yXIATkWm8v33DJuXaqX3TQXMXuxP39cXAyObIQEpCG8DK8lyAeFTUrIFcDmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/plugin-interfaces": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/plugin-interfaces/-/plugin-interfaces-8.5.0.tgz", + "integrity": "sha512-+s/+cCuzk23/a1ViTIugvXFYHN0txxmvqfuL4qx81NlmGawqEYws6xD+L02wNzJ5rbJeAaBhw2pUFStMfG4vYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.5.0", + "@solana/addresses": "8.5.0", + "@solana/instruction-plans": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/rpc-spec": "8.5.0", + "@solana/rpc-subscriptions-spec": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/signers": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/program-client-core": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/program-client-core/-/program-client-core-8.5.0.tgz", + "integrity": "sha512-HJcnbuvQRcWOV3nTUjzgmQRIs0Of1973UU8uOvSEEW7rxycQwNxJ7fVgxQ5sYOCbT+7rlqqTkDeczvB7I4OwoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.5.0", + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/instruction-plans": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/plugin-interfaces": "8.5.0", + "@solana/rpc-api": "8.5.0", + "@solana/signers": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/programs": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/programs/-/programs-8.5.0.tgz", + "integrity": "sha512-8PC2E/d5rwCRw1tFwQlpG65YDN56bYmPiBl99QqCP2RzhNdFJ/52xgFumL2G0uLfD3xsuFaWuOIGNxkyfXYm/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/promises": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/promises/-/promises-8.5.0.tgz", + "integrity": "sha512-qWJbU6RGuZVrtvsGZ+2F46jccaIZDX4czYRRhS3m38cCdE+wKMbqGvWND50xP7D9egOxYKHMqJAJbLQH4gspvQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc/-/rpc-8.5.0.tgz", + "integrity": "sha512-7CQFNOMhXrY/LT0qdsi3h0chy4A/Ao93pyBLqFQqxzlub0st8dC5wl+MDXhkxaBos/DZOu1V4EleWv6qbLMlow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/fast-stable-stringify": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/rpc-api": "8.5.0", + "@solana/rpc-spec": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "@solana/rpc-transformers": "8.5.0", + "@solana/rpc-transport-http": "8.5.0", + "@solana/rpc-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-api/-/rpc-api-8.5.0.tgz", + "integrity": "sha512-qlRZ2yC8WEWNg3VX8UEqlZOitKZaeE6KWbn3A/ybtJAYz+mvr3RMDhnQ89RDqIuxPuAFZNV21BRJv3yB9xR44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/rpc-parsed-types": "8.5.0", + "@solana/rpc-spec": "8.5.0", + "@solana/rpc-transformers": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-parsed-types": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-parsed-types/-/rpc-parsed-types-8.5.0.tgz", + "integrity": "sha512-ktygZ019trgAyjr8y1GN1x4x6wJYw2Ola6UogAfJT5LoNs6kV3k84yCNAlGv34xRPqCVxlWo4nsIWJf8yWQQ2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec/-/rpc-spec-8.5.0.tgz", + "integrity": "sha512-0oET5yc880zFGqBMJE2UB7sxW8sIs4hmsP4bmkR2y4ipm1aScC5U+Rd8GaIrupBOVOFj+RXIorC7kQycN/9nhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "@solana/subscribable": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-spec-types": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-spec-types/-/rpc-spec-types-8.5.0.tgz", + "integrity": "sha512-YUTMKWWlHCGBmA1wxhIilbxJlad5vEsvc8dDO9VWr7GVkQUlipEU79iGaKk4xQ6Oo1mZWxpGz5mAJUqGraTK4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions/-/rpc-subscriptions-8.5.0.tgz", + "integrity": "sha512-8hdWSDKKtMK3kRrR8TExwFo/bWHxVTu4zJ/JkePTQgNtm95kX25/hy+9JHKRtSsNUfXXYM60sQVE18cGgMU7dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/fast-stable-stringify": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/promises": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "@solana/rpc-subscriptions-api": "8.5.0", + "@solana/rpc-subscriptions-channel-websocket": "8.5.0", + "@solana/rpc-subscriptions-spec": "8.5.0", + "@solana/rpc-transformers": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/subscribable": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-api/-/rpc-subscriptions-api-8.5.0.tgz", + "integrity": "sha512-PJaAyqZkpG8vuzdxmdWMwp1JS5pyZ6PuL8OMhx6nCt3phkseqIs1NmAf8pQc/RZjIV3hJMMVCwSDG1DE8DY64w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/rpc-subscriptions-spec": "8.5.0", + "@solana/rpc-transformers": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-channel-websocket/-/rpc-subscriptions-channel-websocket-8.5.0.tgz", + "integrity": "sha512-sRYYwBFg31z77zfKpJBcAO9MaUWa+bO1PlBcDiToa4luXGuR3dC8TSpi1Yip4T4NCONNzK3yhyeI6sHhGHc8Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/rpc-subscriptions-spec": "8.5.0", + "@solana/subscribable": "8.5.0", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-channel-websocket/node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-subscriptions-spec": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-subscriptions-spec/-/rpc-subscriptions-spec-8.5.0.tgz", + "integrity": "sha512-YdCPLHOcQWz8JhMaT/E+YC56nFDJ6D+XOTr6qRWHGe/1PIYGQDVvaFzI7+tF0b6tuv49GoMbpGdkiTX2v6KeUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/promises": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "@solana/subscribable": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transformers": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transformers/-/rpc-transformers-8.5.0.tgz", + "integrity": "sha512-JYq+EyfzJ1ksZubU9VHynQY1Z3k1tosdxG6KE4Cua/rFuXPosSn7o5h9FLLbSB5cTVcnj0lGNrBaVYW4AVK4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/nominal-types": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "@solana/rpc-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-transport-http/-/rpc-transport-http-8.5.0.tgz", + "integrity": "sha512-xfIFZa+oxXLgA6BXaE2bXjiy2yZM1qO1URzzF0lmTekJM/okS8EZAQwEicWpYkvfdHJZ2x/oagD3AOSBBCWrsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/rpc-spec": "8.5.0", + "@solana/rpc-spec-types": "8.5.0", + "undici-types": "^8.11.2" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/rpc-transport-http/node_modules/undici-types": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.12.3.tgz", + "integrity": "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@solana/rpc-types": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/rpc-types/-/rpc-types-8.5.0.tgz", + "integrity": "sha512-L050pZqQ8gaFABF26NAYoatyZZeJo52AKvQQ3i58LRtUc6wcrpCF+4x4U/EDER2YVAc6Qx1xWvGnAomGhI6vzw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/fixed-points": "8.5.0", + "@solana/nominal-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/signers": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/signers/-/signers-8.5.0.tgz", + "integrity": "sha512-F9VvFsG9Av+Sbs4mQpuVlszb6msujxnqKQqa4TsRT24GOqKOFr4Xvs7HqgVJV/BPIDnz7u0omUhUILesHJDfkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/nominal-types": "8.5.0", + "@solana/offchain-messages": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/subscribable": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/subscribable/-/subscribable-8.5.0.tgz", + "integrity": "sha512-v1/hL/l4kubwYVuaMNmtQcmTgDuKG6e9Hjv9fCxkYCdDIUERgmm9DX/So1386+6rkQBVRC7wTSH86Jq+RIqNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/errors": "8.5.0", + "@solana/promises": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/sysvars": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/sysvars/-/sysvars-8.5.0.tgz", + "integrity": "sha512-vidKFEOAujEVizDUHFCmmIpHsS3Zu/fXJKcDUtbUn+bwD9Z1fRiper8rZW3Qdtgu1GmrH3O8MoyL2Nrp7k6QNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/accounts": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-data-structures": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/rpc-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-confirmation": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-confirmation/-/transaction-confirmation-8.5.0.tgz", + "integrity": "sha512-dUVHhyaJ9tt535ZJfgPAdKo6zycCAn1mqKKP/FYvCz/4FiAKnyvqj8rxd66yvvuJVhkpjbcPDDagm/P8JEYUug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/promises": "8.5.0", + "@solana/rpc": "8.5.0", + "@solana/rpc-subscriptions": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-introspection": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-introspection/-/transaction-introspection-8.5.0.tgz", + "integrity": "sha512-VPYVLcpieu5YwwC1wmh3zZ/KHWmqJWPS7iMPVpf90L79/1Dvkg+YYQHnVeN4VQdb7VFnfpTqUnSQpfERDk0Sag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/transaction-messages": "8.5.0", + "@solana/transactions": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transaction-messages": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/transaction-messages/-/transaction-messages-8.5.0.tgz", + "integrity": "sha512-8SHxirU5K0kFmOwwD6XTGI5LWP/bLvLBDbemT4xDOaL+6xkLzlTMtzlgKb/Td5UtqyYOoew0FBVLJzJx/QApOw==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-data-structures": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/nominal-types": "8.5.0", + "@solana/rpc-types": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/transactions": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@solana/transactions/-/transactions-8.5.0.tgz", + "integrity": "sha512-IGRWI38rq5b94gy93wn/gVSQBuwNPSUv3GnWkd6R6Tc7jLbfRlYbYHJNHH5o28Fmlr+nlEn4bgu3BiqMWxuLmg==", + "license": "MIT", + "dependencies": { + "@solana/addresses": "8.5.0", + "@solana/codecs-core": "8.5.0", + "@solana/codecs-data-structures": "8.5.0", + "@solana/codecs-numbers": "8.5.0", + "@solana/codecs-strings": "8.5.0", + "@solana/errors": "8.5.0", + "@solana/functional": "8.5.0", + "@solana/instructions": "8.5.0", + "@solana/keys": "8.5.0", + "@solana/nominal-types": "8.5.0", + "@solana/rpc-types": "8.5.0", + "@solana/transaction-messages": "8.5.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@solana/wallet-standard-features": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.5.0.tgz", + "integrity": "sha512-gxlLaEfPAqbhd3LNkK0Ks2pbVvDm1SDGqBK/ynDQy13Gu+qiURLpRCfSPjbtrw5bmqQsZ6rS2YJHL31bY/Koag==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@streamparser/json": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.22.tgz", + "integrity": "sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@streamparser/json-node": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@streamparser/json-node/-/json-node-0.0.22.tgz", + "integrity": "sha512-sJT2ptNRwqB1lIsQrQlCoWk5rF4tif9wDh+7yluAGijJamAhrHGYpFB/Zg3hJeceoZypi74ftXk8DHzwYpbZSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@streamparser/json": "^0.0.22" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/node/node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/postmate": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/postmate/-/postmate-1.5.4.tgz", + "integrity": "sha512-eZBPKSmEF+0s7dL6htpj3PJdVw9rbdsUXpydGpyQ+PhRPyuTJF1XsHm2thahXIKaHQnqzG2GUaY6m9xNK0sOeA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@wallet-standard/app": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/app/-/app-1.1.1.tgz", + "integrity": "sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", + "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz", + "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/wallet": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/wallet/-/wallet-1.1.1.tgz", + "integrity": "sha512-8WiRPaKk/wNNRZhB2eVhpR/JW7/aqTCMoZhgVUCujuzDmxxmGvsosMxdCG4NAdYkoyozAHCX8/xLtlWUn5mNdQ==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz", + "integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.3.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-install": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/agent-install/-/agent-install-0.0.5.tgz", + "integrity": "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "yaml": "^2.8.3" + }, + "bin": { + "agent-install": "bin/agent-install.mjs" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-path-resolve": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", + "integrity": "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-windows": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cbor-extract": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" + } + }, + "node_modules/cbor-x": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.4.tgz", + "integrity": "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.2" + } + }, + "node_modules/cbor2": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-1.12.0.tgz", + "integrity": "sha512-3Cco8XQhi27DogSp9Ri6LYNZLi/TBY/JVnDe+mj06NkBjW/ZYOtekaEU4wZ4xcRMNrFkDv8KNtOAqHyDfz3lYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.7" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/conf": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", + "integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/deslop-js": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/deslop-js/-/deslop-js-0.9.1.tgz", + "integrity": "sha512-eo3Tn37bqC1mMS3BwsdwRITYgQQQdT30g8SMY9C/zRbMu6ekNf9o7aGNUEkxWiKd1dTND58Sndi5fBlT9V6UvA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@oxc-project/types": "^0.141.0", + "fast-glob": "^3.3.3", + "minimatch": "^10.2.5", + "oxc-parser": "^0.141.0", + "oxc-resolver": "^11.24.2", + "typescript": ">=5.0.4 <6" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.2.0.tgz", + "integrity": "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "dev": true, + "license": "ISC" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ethereum-cryptography": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", + "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "1.4.2", + "@noble/hashes": "1.4.0", + "@scure/bip32": "1.4.0", + "@scure/bip39": "1.3.0" + } + }, + "node_modules/ethereum-cryptography/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/extendable-error": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", + "integrity": "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/forge-std": { + "version": "1.9.4", + "resolved": "git+ssh://git@github.com/foundry-rs/forge-std.git#1eea5bae12ae557d589f9f0f0edae2faa47cb262", + "dev": true, + "license": "(Apache-2.0 OR MIT)" + }, + "node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hardhat": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-3.9.0.tgz", + "integrity": "sha512-MjLHYgxCbqhpLfu6FBkHkhTu9igJmtwgILJwdw7XUZ3xjnXLdGeXtwhykPA16hjeRrmWZH4yv5Tv4maw0dJASw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nomicfoundation/edr": "0.12.0", + "@nomicfoundation/hardhat-errors": "^3.0.15", + "@nomicfoundation/hardhat-utils": "^4.1.3", + "@nomicfoundation/hardhat-vendored": "^3.0.4", + "@nomicfoundation/hardhat-zod-utils": "^3.0.5", + "@nomicfoundation/solidity-analyzer": "^0.1.1", + "@sentry/core": "^9.4.0", + "adm-zip": "^0.4.16", + "chokidar": "^4.0.3", + "enquirer": "^2.3.0", + "ethereum-cryptography": "^2.2.1", + "micro-eth-signer": "^0.14.0", + "p-map": "^7.0.2", + "resolve.exports": "^2.0.3", + "semver": "^7.6.3", + "tsx": "^4.19.3", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "bin": { + "hardhat": "dist/src/cli.js" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/human-id": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", + "integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.2.tgz", + "integrity": "sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-in-the-middle": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.2.tgz", + "integrity": "sha512-jTd2FfOgOWOdgjkHuk/1Ms8VKFXkPs15ymYBETw1sAOrO/dY3XeGVRWir9qBbw7pXr0T2eTFwfCZ+N02HmiNGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "dev": true, + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-subdir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", + "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "better-path-resolve": "1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-stream-stringify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/json-stream-stringify/-/json-stream-stringify-3.1.7.tgz", + "integrity": "sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=7.10.1" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/micro-eth-signer": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/micro-eth-signer/-/micro-eth-signer-0.14.0.tgz", + "integrity": "sha512-5PLLzHiVYPWClEvZIXXFu5yutzpadb73rnQCpUqIHu3No3coFuWQNfE5tkBQJ7djuLYl6aRLaS0MgWJYGoqiBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.1", + "@noble/hashes": "~1.7.1", + "micro-packed": "~0.7.2" + } + }, + "node_modules/micro-eth-signer/node_modules/@noble/curves": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.2.tgz", + "integrity": "sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.2" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-eth-signer/node_modules/@noble/hashes": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", + "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-packed": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.7.3.tgz", + "integrity": "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-packed/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/ndjson": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-2.0.0.tgz", + "integrity": "sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "json-stringify-safe": "^5.0.1", + "minimist": "^1.2.5", + "readable-stream": "^3.6.0", + "split2": "^3.0.0", + "through2": "^4.0.0" + }, + "bin": { + "ndjson": "cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outdent": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", + "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/ox": { + "version": "0.14.29", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", + "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/oxc-parser": { + "version": "0.141.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.141.0.tgz", + "integrity": "sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.141.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.141.0", + "@oxc-parser/binding-android-arm64": "0.141.0", + "@oxc-parser/binding-darwin-arm64": "0.141.0", + "@oxc-parser/binding-darwin-x64": "0.141.0", + "@oxc-parser/binding-freebsd-x64": "0.141.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.141.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.141.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.141.0", + "@oxc-parser/binding-linux-arm64-musl": "0.141.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.141.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.141.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.141.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.141.0", + "@oxc-parser/binding-linux-x64-gnu": "0.141.0", + "@oxc-parser/binding-linux-x64-musl": "0.141.0", + "@oxc-parser/binding-openharmony-arm64": "0.141.0", + "@oxc-parser/binding-wasm32-wasi": "0.141.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.141.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.141.0", + "@oxc-parser/binding-win32-x64-msvc": "0.141.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, + "node_modules/oxlint": { + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.74.0.tgz", + "integrity": "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.74.0", + "@oxlint/binding-android-arm64": "1.74.0", + "@oxlint/binding-darwin-arm64": "1.74.0", + "@oxlint/binding-darwin-x64": "1.74.0", + "@oxlint/binding-freebsd-x64": "1.74.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.74.0", + "@oxlint/binding-linux-arm-musleabihf": "1.74.0", + "@oxlint/binding-linux-arm64-gnu": "1.74.0", + "@oxlint/binding-linux-arm64-musl": "1.74.0", + "@oxlint/binding-linux-ppc64-gnu": "1.74.0", + "@oxlint/binding-linux-riscv64-gnu": "1.74.0", + "@oxlint/binding-linux-riscv64-musl": "1.74.0", + "@oxlint/binding-linux-s390x-gnu": "1.74.0", + "@oxlint/binding-linux-x64-gnu": "1.74.0", + "@oxlint/binding-linux-x64-musl": "1.74.0", + "@oxlint/binding-openharmony-arm64": "1.74.0", + "@oxlint/binding-win32-arm64-msvc": "1.74.0", + "@oxlint/binding-win32-ia32-msvc": "1.74.0", + "@oxlint/binding-win32-x64-msvc": "1.74.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-plugin-react-doctor": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/oxlint-plugin-react-doctor/-/oxlint-plugin-react-doctor-0.9.1.tgz", + "integrity": "sha512-yCW8USbiuszbVsUMN4fL1iU7mRu3Ae3w96+k/xqCyWvW6bF6DzCMtLy5N/w6WLRX78iQsWxVqW/SEgzRBXLfsA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@typescript-eslint/types": "^8.59.3", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "oxc-parser": "^0.141.0" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } + }, + "node_modules/p-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", + "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-filter/node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postmate": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/postmate/-/postmate-1.5.2.tgz", + "integrity": "sha512-EHLlEmrUA/hALls49oBrtE7BzDXXjB9EiO4MZpsoO3R/jRuBmD+2WKQuYAbeuVEpTzrPpUTT79z2cz4qaFgPRg==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-doctor": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-doctor/-/react-doctor-0.9.1.tgz", + "integrity": "sha512-pKCWENXuYZK7UNBHpNLbEBlpy+oVWwPnOxTD2B//gmrQLm+UvIknArzP3IOjNP5Nk1NKCR9uavypFA1QMbYFyg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@sentry/node": "^10.54.0", + "agent-install": "0.0.5", + "conf": "^15.1.0", + "confbox": "^0.2.4", + "deslop-js": "0.9.1", + "eslint-plugin-react-hooks": "^7.1.1", + "figures": "^6.1.0", + "ink": "^7.1.0", + "ink-spinner": "^5.0.0", + "jiti": "^2.7.0", + "magicast": "^0.5.3", + "oxlint": ">=1.74.0 <1.75.0", + "oxlint-plugin-react-doctor": "0.9.1", + "prompts": "^2.4.2", + "react": "19.2.5", + "typescript": ">=5.0.4 <6", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "yaml": "^2.9.0" + }, + "bin": { + "react-doctor": "bin/react-doctor.js" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } + }, + "node_modules/react-doctor/node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-yaml-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", + "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.5", + "js-yaml": "^3.6.1", + "pify": "^4.0.1", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/read-yaml-file/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/read-yaml-file/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spawndamnit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", + "integrity": "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "cross-spawn": "^7.0.5", + "signal-exit": "^4.0.1" + } + }, + "node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "license": "ISC", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-brand": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ts-brand/-/ts-brand-0.2.0.tgz", + "integrity": "sha512-H5uo7OqMvd91D2EefFmltBP9oeNInNzWLAZUSt6coGDn8b814Eis6SnEtzyXORr9ccEb38PfzyiRVDacdkycSQ==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.53.1", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.53.1.tgz", + "integrity": "sha512-FhfJ/SW73CVosiyVLmIMVgKDRKYV1AGCLzZoHYvmNayyVff63Qi1ocPCk59LqC/cNw244RbBJjHnmxqXkE7NpA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.29", + "ws": "8.20.1" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "packages/client": { + "name": "@prf-wallet/client", + "version": "0.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@prf-wallet/postmate": "*" + }, + "devDependencies": { + "tsx": "^4.19.0" + } + }, + "packages/inner-wallet": { + "name": "@prf-wallet/inner-wallet", + "version": "0.0.0", + "extraneous": true, + "license": "MIT" + }, + "packages/onchain": { + "name": "@prf-wallet/onchain", + "version": "0.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@prf-wallet/inner-wallet": "*" + }, + "devDependencies": { + "@nomicfoundation/hardhat-ignition": "^3.1.7", + "@nomicfoundation/hardhat-toolbox-viem": "^5.0.7", + "@types/node": "^22.19.0", + "forge-std": "github:foundry-rs/forge-std#v1.9.4", + "hardhat": "^3.9.0", + "viem": "^2.52.0" + } + }, + "packages/ows-oid4": { + "name": "@1shotapi/ows-oid4", + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "@1shotapi/ows-types": "*", + "jose": "^6.0.11" + }, + "devDependencies": { + "tsx": "^4.19.0" + } + }, + "packages/ows-onchain": { + "name": "@1shotapi/ows-onchain", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@1shotapi/ows-signer": "*" + }, + "devDependencies": { + "@nomicfoundation/hardhat-ignition": "^3.1.7", + "@nomicfoundation/hardhat-toolbox-viem": "^5.0.7", + "@types/node": "^22.19.0", + "forge-std": "github:foundry-rs/forge-std#v1.9.4", + "hardhat": "^3.9.0", + "viem": "^2.52.0" + } + }, + "packages/ows-onchain/node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/ows-provider": { + "name": "@1shotapi/ows-provider", + "version": "0.7.0", + "license": "MIT", + "dependencies": { + "@1shotapi/ows-types": "*", + "@scure/base": "^1.2.6", + "@sd-jwt/sd-jwt-vc": "^0.20.0", + "@solana/addresses": "^8.5.0", + "@solana/keys": "^8.5.0", + "@solana/transactions": "^8.5.0", + "@solana/wallet-standard-features": "^1.5.0", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "@wallet-standard/wallet": "^1.1.1", + "postmate": "^1.5.2" + }, + "devDependencies": { + "@types/postmate": "^1.5.4", + "tsx": "^4.19.0" + } + }, + "packages/ows-provider/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/ows-signer": { + "name": "@1shotapi/ows-signer", + "version": "0.4.3", + "license": "MIT" + }, + "packages/ows-signer-utils": { + "name": "@1shotapi/ows-signer-utils", + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "@1shotapi/ows-types": "*", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@scure/base": "^1.2.5", + "@scure/btc-signer": "^2.4.1" + }, + "devDependencies": { + "@solana/kit": "^8.5.0", + "tsx": "^4.19.0", + "viem": "^2.52.0" + }, + "peerDependencies": { + "@solana/kit": "^8.5.0", + "viem": "^2.0.0" + } + }, + "packages/ows-signer-utils/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/ows-signer-utils/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/ows-signer-utils/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/ows-types": { + "name": "@1shotapi/ows-types", + "version": "0.14.0", + "license": "MIT", + "dependencies": { + "@scure/base": "^2.4.0", + "@sd-jwt/core": "^0.20.0", + "@sd-jwt/sd-jwt-vc": "^0.20.0", + "@solana/addresses": "^8.5.0", + "ts-brand": "^0.2.0", + "viem": "^2.52.0", + "zod": "^4.6.5" + }, + "devDependencies": { + "tsx": "^4.19.0" + } + }, + "packages/ows-types/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/ows-types/node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/ows-wallet-utils": { + "name": "@1shotapi/ows-wallet-utils", + "version": "0.6.0", + "license": "MIT", + "dependencies": { + "@1shotapi/ows-types": "*", + "@scure/base": "^2.4.0", + "postmate": "^1.5.2", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/postmate": "^1.5.4", + "tsx": "^4.19.0", + "viem": "^2.52.0" + }, + "peerDependencies": { + "viem": "^2.0.0" + } + }, + "packages/ows-wallet-utils/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/ows-wallet-utils/node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/postmate": { + "name": "@prf-wallet/postmate", + "version": "0.0.0", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "tsx": "^4.19.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2f7117 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "open-wallet", + "private": true, + "description": "1Shot API Open Wallet Standard (OWS) — reference implementation monorepo", + "workspaces": [ + "packages/*", + "examples/*" + ], + "scripts": { + "build": "npm run build -w @1shotapi/ows-types && npm run build --workspaces --if-present", + "clean": "npm run clean --workspaces --if-present", + "test": "npm run test --workspaces --if-present", + "lint": "npm run lint --workspaces --if-present", + "dev:general-wallet": "npm run dev -w @1shotapi/ows-example-general-wallet", + "dev:credential-issuer": "npm run dev -w @1shotapi/ows-example-credential-issuer", + "dev:credential-verifier": "npm run dev -w @1shotapi/ows-example-credential-verifier", + "changeset": "changeset", + "version-packages": "changeset version", + "release": "npm run build && changeset publish", + "doctor": "npx react-doctor@latest", + "skills:install": "npx skills add 1Shot-API/skills --skill ows-branding-layer --skill 1shot-wallet -g -a cursor -y" + }, + "devDependencies": { + "@changesets/cli": "^2.29.0", + "@types/node": "^22.19.0", + "react-doctor": "^0.9.1", + "typescript": "~5.8.0" + }, + "engines": { + "node": ">=22" + }, + "overrides": { + "keyv": "4.5.4", + "flat-cache": "4.0.1", + "file-entry-cache": "8.0.0", + "cacheable": "2.5.0", + "cacheable-request": "13.0.19", + "@cacheable/memory": "2.2.0", + "@cacheable/utils": "2.5.0", + "@cacheable/node-cache": "3.1.1", + "@cacheable/net": "2.1.0", + "cache-manager": "7.2.9", + "ecto": "5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git" + }, + "license": "MIT" +} diff --git a/packages/ows-oid4/CHANGELOG.md b/packages/ows-oid4/CHANGELOG.md new file mode 100644 index 0000000..54386dd --- /dev/null +++ b/packages/ows-oid4/CHANGELOG.md @@ -0,0 +1,61 @@ +# @1shotapi/ows-oid4 + +## 0.4.0 + +### Minor Changes + +- ebc03aa: Refactor CredentialsHelper to SignHelper-style branding ownership: `approveAndAcceptOffer` / `approveAndPresent`, `release()` instead of `hide()`, and drop helper-level `ensureReady` / boolean approval hooks. + +## 0.3.0 + +### Minor Changes + +- 2f6bb67: Host-owned wallet sizing: flyout vs full-screen drawer, no branding size requests. + + `requestDisplay` is a show session only (no width/height). On small viewports the host panel opens as a full-screen drawer with a bottom wipe; larger viewports keep the lower-right flyout at `walletSizeX`/`walletSizeY`. + +### Patch Changes + +- Updated dependencies [2f6bb67] + - @1shotapi/ows-types@0.2.4 + +## 0.2.0 + +### Minor Changes + +- ae79e0f: Batch digest signing and `executeBatch` mixed ceremony so one passkey covers many signs/AES/auth ops; signing APIs are array-only (breaking). + +### Patch Changes + +- Updated dependencies [ae79e0f] +- Updated dependencies [a39b7b2] + - @1shotapi/ows-types@0.2.0 + +## 0.1.1 + +### Patch Changes + +- fd48ab0: Call ensureReady before acceptOffer display and before present match when the local credential cache is empty, so locked / first-visit wallets unlock before OID4 consent. +- Updated dependencies + - @1shotapi/ows-types@0.1.2 + +## 0.1.0 + +### Minor Changes + +- a78bba8: Phase 4 credentials: optional `@1shotapi/ows-oid4` add-on (`CredentialsHelper`, HTTP OID4VCI/OID4VP clients). Holder bridge is `CredentialCryptoUtils.createOwsEd25519HolderSigner` in `ows-types`. Wire registrar/Zod schemas stay in `ows-wallet-utils`. `IOWSSigner` lives in `ows-types` (implemented by `OWSSigner`). Verifier/issuer HTTP demos own their server-side JWE helpers. + +### Patch Changes + +- Updated dependencies [0e1b1d1] +- Updated dependencies [aaf43d4] +- Updated dependencies [e83e5ce] +- Updated dependencies [3c8b506] +- Updated dependencies [89e40a9] +- Updated dependencies [a78bba8] +- Updated dependencies [9a87faf] +- Updated dependencies [eae834a] +- Updated dependencies [55f9a22] +- Updated dependencies [aaf43d4] +- Updated dependencies [8eeecf2] + - @1shotapi/ows-types@0.1.0 diff --git a/packages/ows-oid4/README.md b/packages/ows-oid4/README.md new file mode 100644 index 0000000..055d583 --- /dev/null +++ b/packages/ows-oid4/README.md @@ -0,0 +1,69 @@ +# @1shotapi/ows-oid4 + +Optional **credentials add-on** for Branding Layers that issue/present via OID4VCI / OID4VP. + +Use with `@1shotapi/ows-wallet-utils` (wire registration hooks) and an `IOWSSigner` +(`OWSSigner` from `@1shotapi/ows-signer-utils`). Skip this package if your branding +layer is crypto-only. + +## Features + +- `CredentialsHelper` — thin adapter: resolve/match/trust → `requestDisplay` → branding `approveAnd*` → `release()` +- `issueCredentialAfterApproval` / `presentCredentialAfterApproval` — PoP + OID4 helpers for branding hooks +- `FetchUtils` / `ParseUtils` — injected `IFetchUtils` / `IParseUtils` defaults +- `HttpOid4vciClient` / `HttpOid4vpClient` — fetch-based OID4 clients (pre-authorized grant, DCQL subset, `direct_post.jwt` encrypt) +- Demo wallet attestation helper + +Holder signing bridge: `createCredentialsHolderSigner` (or `CredentialCryptoUtils.createOwsEd25519HolderSigner` from `@1shotapi/ows-types`). + +Issuer/verifier **server** demos live under `examples/credential-*` (not this package). + +## Install + +```bash +npm install @1shotapi/ows-oid4 @1shotapi/ows-wallet-utils +``` + +## Branding ownership (SignHelper parity) + +```ts +import { + CredentialsHelper, + createCredentialsHolderSigner, + issueCredentialAfterApproval, + presentCredentialAfterApproval, +} from "@1shotapi/ows-oid4"; + +const resolveHolderSigner = createCredentialsHolderSigner(signer); + +new CredentialsHelper(wallet, { + repository, + oid4vci, + oid4vp, + trust, + approveAndAcceptOffer: async (request) => { + // setup-only if needed, consent UI, then: + return issueCredentialAfterApproval({ + offer: request.offer, + metadata: request.metadata, + oid4vci, + repository, + resolveHolderSigner, + getProofNonce, + attestationProvider, + }); + }, + approveAndPresent: async (request) => { + // consent UI, then: + return presentCredentialAfterApproval({ + definition: request.definition, + credential: request.credential, + oid4vp, + resolveHolderSigner, + attestationProvider, + }); + }, +}).register(); +``` + +Setup / unlock and passkey ceremonies belong **inside** `approveAnd*`, not on helper options. diff --git a/packages/ows-oid4/package.json b/packages/ows-oid4/package.json new file mode 100644 index 0000000..dca8040 --- /dev/null +++ b/packages/ows-oid4/package.json @@ -0,0 +1,48 @@ +{ + "name": "@1shotapi/ows-oid4", + "version": "0.4.0", + "description": "Optional OWS credentials add-on — CredentialsHelper, HTTP OID4VCI/OID4VP clients, holder signer bridge", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "node --import tsx --test test/*.test.ts", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@1shotapi/ows-types": "*", + "jose": "^6.0.11" + }, + "devDependencies": { + "tsx": "^4.19.0" + }, + "keywords": [ + "ows", + "openid4vci", + "openid4vp", + "oid4vci", + "oid4vp", + "sd-jwt" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-oid4" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ows-oid4/src/attestation.ts b/packages/ows-oid4/src/attestation.ts new file mode 100644 index 0000000..d293299 --- /dev/null +++ b/packages/ows-oid4/src/attestation.ts @@ -0,0 +1,42 @@ +import * as jose from "jose"; +import type { IWalletAttestationProvider } from "@1shotapi/ows-types"; + +export type DemoWalletAttestationOptions = { + /** Signing private JWK (OKP Ed25519 or EC). */ + privateJwk: JsonWebKey; + /** Optional issuer claim for the attestation JWT. */ + issuer?: string; +}; + +/** + * Demo attestation provider: signs a compact JWT with `typ: oauth-client-attestation+jwt` + * style claims. Not a production trust-rooted attestation. + */ +export class DemoWalletAttestationProvider implements IWalletAttestationProvider { + constructor(private readonly options: DemoWalletAttestationOptions) {} + + async createAttestation(input: { + audience: string; + nonce?: string; + }): Promise { + const alg = + this.options.privateJwk.crv === "Ed25519" || + this.options.privateJwk.kty === "OKP" + ? "EdDSA" + : "ES256"; + const key = await jose.importJWK(this.options.privateJwk, alg); + const now = Math.floor(Date.now() / 1000); + return new jose.SignJWT({ + ...(input.nonce ? { nonce: input.nonce } : {}), + }) + .setProtectedHeader({ + alg, + typ: "oauth-client-attestation+jwt", + }) + .setIssuer(this.options.issuer ?? "ows-demo-wallet") + .setAudience(input.audience) + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(key); + } +} diff --git a/packages/ows-oid4/src/credentials/credentials-helper.ts b/packages/ows-oid4/src/credentials/credentials-helper.ts new file mode 100644 index 0000000..28b0de9 --- /dev/null +++ b/packages/ows-oid4/src/credentials/credentials-helper.ts @@ -0,0 +1,421 @@ +import { + CredentialFormatId, + CredentialTypeName, + HexString, + NoopCredentialStatusValidator, + ProofUtils, + CredentialCryptoUtils, + type CredentialOffer, + type CredentialOfferApprovalRequest, + type CredentialOfferInput, + type CredentialPresentationApprovalRequest, + type CredentialReceipt, + type CredentialFilter, + type CredentialSummary, + type CredentialId, + type ICredentialRepository, + type ICredentialStatusValidator, + type IHolderSigner, + type IIssuerTrustRegistry, + type IOWSSigner, + type IOid4vciClient, + type IOid4vpClient, + type IWalletAttestationProvider, + type IssuerMetadata, + type OpenWalletCredentialProvider, + type PresentationDefinition, + type PresentationRequestInput, + type PresentationResult, + type RequestDisplayParams, + type StoredCredential, +} from "@1shotapi/ows-types"; + +export type CredentialsHelperDisplaySession = { + hide(): Promise; + /** Prefer over `hide` when nested host show / RPC should keep the panel open. */ + release?(): void; +}; + +export type CredentialsHelperWallet = { + requestDisplay( + params?: RequestDisplayParams, + ): Promise; + credentials: { + register(handlers: OpenWalletCredentialProvider): void; + }; +}; + +/** Branding consent + issue (PoP / requestCredential / store). */ +export type ApproveAndAcceptOfferRequest = CredentialOfferApprovalRequest & { + offer: CredentialOffer; + metadata: IssuerMetadata; +}; + +/** Branding consent + present (PoP / buildPresentation). */ +export type ApproveAndPresentRequest = CredentialPresentationApprovalRequest & { + definition: PresentationDefinition; + match: CredentialSummary; + credential: StoredCredential; +}; + +export type CredentialsHelperOptions = { + repository: ICredentialRepository; + oid4vci: IOid4vciClient; + oid4vp: IOid4vpClient; + trust: IIssuerTrustRegistry; + status?: ICredentialStatusValidator; + /** + * Branding owns setup, consent UI, PoP, OID4 request, and vault store. + * Helper opens display, then calls this; session is released afterward. + */ + approveAndAcceptOffer: ( + request: ApproveAndAcceptOfferRequest, + ) => Promise; + /** + * Branding owns setup (if needed), consent UI, PoP, and presentation build. + * Helper opens display with a loaded credential, then calls this. + */ + approveAndPresent: ( + request: ApproveAndPresentRequest, + ) => Promise; + /** + * When true (default), re-check wallet trust on present after host + * `acceptedIssuers` filtering. + */ + recheckTrustOnPresent?: boolean; +}; + +/** + * Thin OID4 adapter: resolve offer/request, trust/match/status, request display, + * call branding `approveAnd*` handlers. Setup, consent, and PoP ceremonies belong + * inside those branding callbacks — not on this helper (mirrors SignHelper). + */ +export class CredentialsHelper { + readonly handlers: OpenWalletCredentialProvider; + + private readonly status: ICredentialStatusValidator; + private readonly recheckTrustOnPresent: boolean; + + constructor( + private readonly wallet: CredentialsHelperWallet, + private readonly options: CredentialsHelperOptions, + ) { + this.status = options.status ?? new NoopCredentialStatusValidator(); + this.recheckTrustOnPresent = options.recheckTrustOnPresent !== false; + + this.handlers = { + acceptOffer: (input) => this.acceptOffer(input), + present: (input) => this.present(input), + list: (filter) => this.list(filter), + delete: (input) => this.delete(input.credentialId), + }; + } + + /** Register handlers on `wallet.credentials`. */ + register(): void { + this.wallet.credentials.register(this.handlers); + } + + async acceptOffer(input: CredentialOfferInput): Promise { + const uri = input.credentialOfferUri; + const offer = + input.offer ?? (uri ? await this.options.oid4vci.resolveOffer(uri) : undefined); + if (!offer) { + throw new Error("credentialOfferUri or offer is required"); + } + + const metadata = await this.options.oid4vci.fetchIssuerMetadata( + offer.credentialIssuer, + ); + + const trusted = await this.options.trust.isTrustedIssuer( + offer.credentialIssuer, + ); + if (!trusted) { + throw new Error(`Untrusted issuer: ${offer.credentialIssuer}`); + } + + const approval = buildOfferApprovalRequest(offer, metadata); + return this.withDisplay(() => + this.options.approveAndAcceptOffer({ + ...approval, + offer, + metadata, + }), + ); + } + + async present(input: PresentationRequestInput): Promise { + const uri = input.requestUri; + const definition = + input.request ?? + (uri ? await this.options.oid4vp.resolveRequest(uri) : undefined); + if (!definition) { + throw new Error("requestUri or request is required"); + } + + const summaries = await this.options.repository.list(); + if (summaries.length === 0) { + throw new Error( + "No credentials in wallet (warm the local vault before present)", + ); + } + + let matches = await this.options.oid4vp.matchCredentials( + definition, + summaries, + ); + + if (input.acceptedIssuers && input.acceptedIssuers.length > 0) { + const allowed = new Set(input.acceptedIssuers); + matches = matches.filter((m) => allowed.has(m.issuer)); + } + + if (this.recheckTrustOnPresent) { + const trusted: typeof matches = []; + for (const match of matches) { + if (await this.options.trust.isTrustedIssuer(match.issuer)) { + trusted.push(match); + } + } + matches = trusted; + } + + if (matches.length === 0) { + throw new Error("No matching credentials in wallet"); + } + + const match = matches[0]!; + const credential = await this.options.repository.get(match.credentialId); + if (!credential) { + throw new Error("Credential not found"); + } + await this.assertActive(credential); + + const approval = buildPresentationApprovalRequest(definition, match); + return this.withDisplay(() => + this.options.approveAndPresent({ + ...approval, + definition, + match, + credential, + }), + ); + } + + async list(filter?: CredentialFilter): Promise { + return this.options.repository.list(filter); + } + + async delete(credentialId: CredentialId): Promise { + await this.options.repository.delete(credentialId); + } + + private async assertActive(credential: StoredCredential): Promise { + const check = await this.status.checkStatus(credential); + if (check.status !== "active") { + throw new Error( + `Credential status is ${check.status}${check.details ? `: ${check.details}` : ""}`, + ); + } + } + + private async withDisplay(run: () => Promise): Promise { + const display = await this.wallet.requestDisplay({}); + try { + return await run(); + } finally { + // Prefer release over hide so host showWallet / in-flight RPC can keep + // the flyout visible (same policy as SignHelper). + if ("release" in display && typeof display.release === "function") { + display.release(); + } else { + await display.hide(); + } + } + } +} + +/** Build the consent UI payload for a credential offer. */ +export function buildOfferApprovalRequest( + offer: CredentialOffer, + metadata: IssuerMetadata, +): CredentialOfferApprovalRequest { + return { + issuerName: formatIssuerName(offer.credentialIssuer), + issuerId: offer.credentialIssuer, + offeredCredentials: offer.credentialConfigurationIds.map((configurationId) => { + const config = metadata.credentialConfigurationsSupported[configurationId]; + return { + configurationId, + format: config?.format ?? CredentialFormatId("sd-jwt-vc"), + scope: config?.scope, + }; + }), + }; +} + +/** Build the consent UI payload for a presentation request. */ +export function buildPresentationApprovalRequest( + definition: PresentationDefinition, + match: CredentialSummary, +): CredentialPresentationApprovalRequest { + return { + verifierName: definition.verifier.name, + verifierId: definition.verifier.id, + requestedClaims: definition.requestedClaims, + credentialType: + match.type.find((t) => t !== CredentialTypeName("VerifiableCredential")) ?? + match.type[0] ?? + CredentialTypeName("Credential"), + credentialIssuer: match.issuer, + }; +} + +/** + * Default OWS Ed25519 holder signer bridged to a Signing Layer instance. + * Branding `approveAnd*` callbacks use this for PoP / KB JWT. + */ +export function createCredentialsHolderSigner( + signer: IOWSSigner, + holderSigner?: IHolderSigner | (() => Promise), +): () => Promise { + return async () => { + if (holderSigner) { + return typeof holderSigner === "function" + ? holderSigner() + : holderSigner; + } + + return CredentialCryptoUtils.createOwsEd25519HolderSigner({ + getEd25519PublicKeyHex: async () => { + const cached = signer.getLastPublicKeyData(); + if (cached?.ed25519PublicKey) { + return cached.ed25519PublicKey; + } + const keys = await signer.getPublicKey({ + credentialId: signer.getCredentialId(), + }); + return keys.ed25519PublicKey; + }, + signDigest: async (digests) => { + const results = await signer.signDigest( + digests.map((item) => ({ + digestData: HexString(item.digestData), + scheme: item.scheme ?? "ed25519", + })), + ); + return results.map((result) => ({ + signature: HexString(result.signature), + })); + }, + }); + }; +} + +/** + * Issue a credential after branding consent: prepare nonce, PoP JWT, + * requestCredential, assert status, store. Call from `approveAndAcceptOffer`. + */ +export async function issueCredentialAfterApproval(options: { + offer: CredentialOffer; + metadata: IssuerMetadata; + oid4vci: IOid4vciClient; + repository: ICredentialRepository; + resolveHolderSigner: () => Promise; + getProofNonce?: (metadata: IssuerMetadata) => string | Promise; + attestationProvider?: IWalletAttestationProvider; + status?: ICredentialStatusValidator; +}): Promise { + const status = options.status ?? new NoopCredentialStatusValidator(); + const holderSigner = await options.resolveHolderSigner(); + const holderPublicKeyJwk = await holderSigner.publicKeyJwk(); + + const prepared = options.oid4vci.prepareCredentialRequest + ? await options.oid4vci.prepareCredentialRequest( + options.offer, + options.metadata, + ) + : undefined; + + const nonce = + prepared?.cNonce ?? + (options.getProofNonce + ? await options.getProofNonce(options.metadata) + : undefined); + if (!nonce) { + throw new Error( + "OID4VCI C-nonce required (prepareCredentialRequest or getProofNonce)", + ); + } + + const walletAttestationJwt = options.attestationProvider + ? await options.attestationProvider.createAttestation({ + audience: options.metadata.credentialIssuer, + nonce, + }) + : undefined; + + const proofJwt = await ProofUtils.buildOid4vciProofJwt({ + holderSigner, + audience: options.metadata.credentialIssuer, + nonce, + }); + const stored = await options.oid4vci.requestCredential( + options.offer, + options.metadata, + { + holderPublicKeyJwk, + proof: { proof_type: "jwt", jwt: proofJwt }, + nonce, + walletAttestationJwt, + }, + ); + + const check = await status.checkStatus(stored); + if (check.status !== "active") { + throw new Error( + `Credential status is ${check.status}${check.details ? `: ${check.details}` : ""}`, + ); + } + await options.repository.store(stored); + + return { + credentialId: stored.credentialId, + format: stored.format, + type: stored.type, + }; +} + +/** + * Build a presentation after branding consent. Call from `approveAndPresent`. + */ +export async function presentCredentialAfterApproval(options: { + definition: PresentationDefinition; + credential: StoredCredential; + oid4vp: IOid4vpClient; + resolveHolderSigner: () => Promise; + attestationProvider?: IWalletAttestationProvider; +}): Promise { + const holderSigner = await options.resolveHolderSigner(); + const authorizationRequest = options.oid4vp.getAuthorizationRequest?.( + options.definition.id, + ); + return options.oid4vp.buildPresentation( + options.credential, + options.definition, + { + holderSigner, + attestationProvider: options.attestationProvider, + authorizationRequest, + }, + ); +} + +function formatIssuerName(issuer: CredentialOffer["credentialIssuer"]): string { + try { + return new URL(issuer).hostname; + } catch { + return issuer; + } +} diff --git a/packages/ows-oid4/src/dcql-mapper.ts b/packages/ows-oid4/src/dcql-mapper.ts new file mode 100644 index 0000000..7d3248c --- /dev/null +++ b/packages/ows-oid4/src/dcql-mapper.ts @@ -0,0 +1,53 @@ +import { + CredentialClaimName, + CredentialTypeName, + type DcqlMapResult, + type OwsDcqlQuery, +} from "@1shotapi/ows-types"; + +/** + * Map a supported DCQL subset into OWS claim-list presentation fields. + * Fails closed on multi-credential queries or unsupported formats. + */ +export function mapDcqlToPresentationFields(query: OwsDcqlQuery): DcqlMapResult { + if (!query.credentials || query.credentials.length === 0) { + throw new Error("DCQL: credentials array is required"); + } + if (query.credentials.length > 1) { + throw new Error("DCQL: multi-credential queries are not supported in Phase 4"); + } + + const cred = query.credentials[0]!; + if (cred.format !== "vc+sd-jwt" && cred.format !== "dc+sd-jwt") { + throw new Error( + `DCQL: unsupported format ${cred.format} (expected vc+sd-jwt or dc+sd-jwt)`, + ); + } + + const credentialTypes = (cred.meta?.vct_values ?? []).map((v) => + CredentialTypeName(v), + ); + + const requestedClaims: CredentialClaimName[] = []; + for (const claim of cred.claims ?? []) { + if (!claim.path || claim.path.length === 0) { + throw new Error("DCQL: claim path must be non-empty"); + } + if (claim.path.length > 1) { + throw new Error( + "DCQL: nested claim paths are not supported (use single claim name)", + ); + } + const name = claim.path[0]!; + if (typeof name !== "string") { + throw new Error("DCQL: claim path segment must be a string"); + } + requestedClaims.push(CredentialClaimName(name)); + } + + if (requestedClaims.length === 0) { + throw new Error("DCQL: at least one claim is required"); + } + + return { requestedClaims, credentialTypes }; +} diff --git a/packages/ows-oid4/src/fetch-json.ts b/packages/ows-oid4/src/fetch-json.ts new file mode 100644 index 0000000..6747ce5 --- /dev/null +++ b/packages/ows-oid4/src/fetch-json.ts @@ -0,0 +1,58 @@ +export type FetchLike = typeof fetch; + +/** + * Fetch helpers for OID4 HTTP clients (JSON / form POST with clear HTTP errors). + */ +export interface IFetchUtils { + fetchJson(url: string, init?: RequestInit): Promise; + fetchFormJson( + url: string, + fields: Record, + ): Promise; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +/** + * Default {@link IFetchUtils} backed by global `fetch` (or a test double). + */ +export class FetchUtils implements IFetchUtils { + private readonly fetchImpl: FetchLike; + + constructor(fetchImpl?: FetchLike) { + // Do not store unbound `window.fetch` — calling it later throws Illegal invocation. + this.fetchImpl = + fetchImpl ?? ((input, init) => globalThis.fetch(input, init)); + } + + /** Fetch JSON with a clear error when the response is not OK. */ + public async fetchJson(url: string, init?: RequestInit): Promise { + const response = await this.fetchImpl(url, init); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `HTTP ${response.status} ${response.statusText} for ${url}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + return (await response.json()) as T; + } + + /** POST form-urlencoded body (OID4VCI token endpoint). */ + public async fetchFormJson( + url: string, + fields: Record, + ): Promise { + const body = new URLSearchParams(fields); + return this.fetchJson(url, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body, + }); + } + + /** Raw fetch using the configured implementation. */ + public async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return this.fetchImpl(input, init); + } +} diff --git a/packages/ows-oid4/src/http-oid4vci-client.ts b/packages/ows-oid4/src/http-oid4vci-client.ts new file mode 100644 index 0000000..c059fd0 --- /dev/null +++ b/packages/ows-oid4/src/http-oid4vci-client.ts @@ -0,0 +1,254 @@ +import { + CredentialConfigurationId, + CredentialFormatId, + CredentialIssuer, + CredentialScope, + UriString, + type CredentialIssuanceContext, + type CredentialOffer, + type CredentialOfferUri, + type CredentialRequestPreparation, + type IOid4vciClient, + type IssuerMetadata, + type StoredCredential, +} from "@1shotapi/ows-types"; +import { type IFetchUtils } from "./fetch-json.js"; +import { type IParseUtils } from "./parse-utils.js"; +import { storedCredentialFromSdJwtVc } from "./sd-jwt-stored.js"; + +type WellKnownIssuer = { + credential_issuer: string; + credential_endpoint: string; + token_endpoint?: string; + authorization_servers?: string[]; + jwks_uri?: string; + credential_configurations_supported?: Record< + string, + { + format: string; + scope?: string; + proof_types_supported?: Record; + } + >; +}; + +type TokenResponse = { + access_token: string; + token_type?: string; + expires_in?: number; + c_nonce?: string; + c_nonce_expires_in?: number; +}; + +type CredentialResponse = { + credential?: string; + credentials?: Array; + c_nonce?: string; +}; + +type Session = { + accessToken: string; + cNonce?: string; +}; + +/** + * HTTP OID4VCI client: offer URI → well-known → pre-authorized token → credential. + */ +export class HttpOid4vciClient implements IOid4vciClient { + private session: Session | undefined; + + constructor( + protected readonly fetchUtils: IFetchUtils, + protected readonly parseUtils: IParseUtils, + ) {} + + async resolveOffer(uri: CredentialOfferUri): Promise { + const parsed = this.parseUtils.parseCredentialOfferUri(uri); + if (parsed.kind === "https") { + const raw = await this.fetchUtils.fetchJson<{ + credential_issuer: string; + credential_configuration_ids: string[]; + grants?: CredentialOffer["grants"]; + }>(parsed.url); + return this.parseUtils.normalizeCredentialOffer(raw); + } + + const fromQuery = this.parseUtils.offerFromQueryParams(parsed.query); + if (fromQuery.offer) { + return fromQuery.offer; + } + if (fromQuery.credentialOfferUri) { + const raw = await this.fetchUtils.fetchJson<{ + credential_issuer: string; + credential_configuration_ids: string[]; + grants?: CredentialOffer["grants"]; + }>(fromQuery.credentialOfferUri); + return this.parseUtils.normalizeCredentialOffer(raw); + } + throw new Error("resolveOffer: empty offer"); + } + + async fetchIssuerMetadata(issuer: CredentialIssuer): Promise { + const base = String(issuer).replace(/\/$/, ""); + const wellKnown = await this.fetchUtils.fetchJson( + `${base}/.well-known/openid-credential-issuer`, + ); + + const configs: IssuerMetadata["credentialConfigurationsSupported"] = {}; + for (const [id, cfg] of Object.entries( + wellKnown.credential_configurations_supported ?? {}, + )) { + configs[CredentialConfigurationId(id)] = { + format: CredentialFormatId(cfg.format), + scope: cfg.scope ? CredentialScope(cfg.scope) : undefined, + }; + } + + let tokenEndpoint = wellKnown.token_endpoint; + if (!tokenEndpoint && wellKnown.authorization_servers?.[0]) { + const asBase = wellKnown.authorization_servers[0].replace(/\/$/, ""); + const asMeta = await this.fetchUtils + .fetchJson<{ token_endpoint?: string }>( + `${asBase}/.well-known/oauth-authorization-server`, + ) + .catch(() => undefined); + tokenEndpoint = asMeta?.token_endpoint ?? `${asBase}/token`; + } + + const proofTypes: string[] = []; + for (const cfg of Object.values( + wellKnown.credential_configurations_supported ?? {}, + )) { + if (cfg.proof_types_supported) { + proofTypes.push(...Object.keys(cfg.proof_types_supported)); + } + } + + return { + credentialIssuer: CredentialIssuer(wellKnown.credential_issuer), + credentialConfigurationsSupported: configs, + credentialEndpoint: UriString(wellKnown.credential_endpoint), + tokenEndpoint: tokenEndpoint + ? UriString(tokenEndpoint) + : UriString(`${base}/token`), + jwksUri: wellKnown.jwks_uri + ? UriString(wellKnown.jwks_uri) + : undefined, + proofTypesSupported: proofTypes.length > 0 ? proofTypes : ["jwt"], + }; + } + + async prepareCredentialRequest( + offer: CredentialOffer, + metadata: IssuerMetadata, + ): Promise { + const grant = + offer.grants?.["urn:ietf:params:oauth:grant-type:pre-authorized_code"]; + if (!grant?.["pre-authorized_code"]) { + throw new Error( + "HttpOid4vciClient: pre-authorized_code grant is required", + ); + } + if (!metadata.tokenEndpoint) { + throw new Error("HttpOid4vciClient: tokenEndpoint missing from metadata"); + } + + const token = await this.fetchUtils.fetchFormJson( + String(metadata.tokenEndpoint), + { + grant_type: "urn:ietf:params:oauth:grant-type:pre-authorized_code", + "pre-authorized_code": grant["pre-authorized_code"], + }, + ); + + this.session = { + accessToken: token.access_token, + cNonce: token.c_nonce, + }; + + return { + accessToken: token.access_token, + cNonce: token.c_nonce, + cNonceExpiresIn: token.c_nonce_expires_in, + }; + } + + async requestCredential( + offer: CredentialOffer, + metadata: IssuerMetadata, + context?: CredentialIssuanceContext, + ): Promise { + if (!context?.proof || !context.holderPublicKeyJwk) { + throw new Error( + "HttpOid4vciClient: CredentialIssuanceContext with proof is required", + ); + } + if (!metadata.credentialEndpoint) { + throw new Error( + "HttpOid4vciClient: credentialEndpoint missing from metadata", + ); + } + if (!this.session?.accessToken) { + await this.prepareCredentialRequest(offer, metadata); + } + const accessToken = this.session?.accessToken; + if (!accessToken) { + throw new Error("HttpOid4vciClient: missing access token"); + } + + const configurationId = offer.credentialConfigurationIds[0]; + if (!configurationId) { + throw new Error("HttpOid4vciClient: offer has no configuration ids"); + } + + const body: Record = { + format: + metadata.credentialConfigurationsSupported[configurationId]?.format ?? + "vc+sd-jwt", + credential_configuration_id: configurationId, + proof: context.proof, + }; + if (context.walletAttestationJwt) { + body.wallet_attestation = context.walletAttestationJwt; + } + + const response = await this.fetchUtils.fetchJson( + String(metadata.credentialEndpoint), + { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + + if (response.c_nonce) { + this.session = { + accessToken, + cNonce: response.c_nonce, + }; + } + + const sdJwt = this.extractCredentialString(response); + return await storedCredentialFromSdJwtVc({ + sdJwt, + fallbackIssuer: offer.credentialIssuer, + }); + } + + protected extractCredentialString(response: CredentialResponse): string { + if (typeof response.credential === "string") { + return response.credential; + } + const first = response.credentials?.[0]; + if (typeof first === "string") { + return first; + } + if (first && typeof first === "object" && typeof first.credential === "string") { + return first.credential; + } + throw new Error("HttpOid4vciClient: credential response missing credential"); + } +} diff --git a/packages/ows-oid4/src/http-oid4vp-client.ts b/packages/ows-oid4/src/http-oid4vp-client.ts new file mode 100644 index 0000000..e7bec9a --- /dev/null +++ b/packages/ows-oid4/src/http-oid4vp-client.ts @@ -0,0 +1,243 @@ +import { + CredentialClaimName, + CredentialTypeName, + UriString, + PresentationUtils, + type CredentialSummary, + type IOid4vpClient, + type Oid4vpAuthorizationRequest, + type OwsDcqlQuery, + type PresentationBuildContext, + type PresentationDefinition, + type PresentationRequestUri, + type PresentationResult, + type StoredCredential, +} from "@1shotapi/ows-types"; +import { mapDcqlToPresentationFields } from "./dcql-mapper.js"; +import { type IFetchUtils } from "./fetch-json.js"; +import { encryptPresentationResponse } from "./jwe.js"; + +type RawAuthRequest = { + client_id?: string; + nonce?: string; + response_uri?: string; + response_mode?: string; + client_metadata?: Oid4vpAuthorizationRequest["clientMetadata"]; + dcql_query?: OwsDcqlQuery; + presentation_definition?: { + id?: string; + requested_claims?: string[]; + credential_types?: string[]; + nonce?: string; + audience?: string; + verifier?: { id: string; name: string }; + }; +}; + +/** + * HTTP OID4VP client: request_uri → DCQL/PD map → SD-JWT presentation → optional encrypt/POST. + */ +export class HttpOid4vpClient implements IOid4vpClient { + private readonly byDefinitionId = new Map(); + + constructor(protected readonly fetchUtils: IFetchUtils) {} + + async resolveRequest( + uri: PresentationRequestUri, + ): Promise { + const url = String(uri); + const raw = await this.fetchUtils.fetchJson(url); + const auth = this.normalizeAuthRequest(raw, url); + this.byDefinitionId.set(auth.presentationDefinition.id, auth); + return auth.presentationDefinition; + } + + getAuthorizationRequest( + definitionId: string, + ): Oid4vpAuthorizationRequest | undefined { + return this.byDefinitionId.get(definitionId); + } + + async matchCredentials( + definition: PresentationDefinition, + credentials: CredentialSummary[], + ): Promise { + const types = definition.credentialTypes ?? []; + return credentials.filter((c) => { + if (types.length === 0) return true; + return types.some((t) => c.type.includes(t)); + }); + } + + async buildPresentation( + credential: StoredCredential, + definition: PresentationDefinition, + context?: PresentationBuildContext, + ): Promise { + if (!context?.holderSigner) { + throw new Error("HttpOid4vpClient: holderSigner is required"); + } + if (credential.format !== "sd-jwt-vc") { + throw new Error( + `HttpOid4vpClient: unsupported format ${credential.format}`, + ); + } + + const auth = + context.authorizationRequest ?? + this.byDefinitionId.get(definition.id); + + if ( + auth?.clientMetadata?.require_wallet_attestation && + context.attestationProvider + ) { + await context.attestationProvider.createAttestation({ + audience: auth.clientId, + nonce: definition.nonce ?? auth.nonce, + }); + } + + const built = await PresentationUtils.build({ + credential, + definition, + holderSigner: context.holderSigner, + }); + + let encryptedResponse: string | undefined; + const responseMode = auth?.responseMode; + const presentation = built.presentation; + + if (responseMode === "direct_post.jwt") { + const jwk = auth?.clientMetadata?.jwks?.keys?.[0]; + if (!jwk) { + throw new Error( + "HttpOid4vpClient: direct_post.jwt requires client_metadata.jwks", + ); + } + encryptedResponse = await encryptPresentationResponse( + String(presentation), + jwk, + { + alg: auth.clientMetadata?.authorization_encrypted_response_alg, + enc: auth.clientMetadata?.authorization_encrypted_response_enc, + }, + ); + } + + let submittedToResponseUri = false; + if (auth?.responseUri) { + const body = new URLSearchParams(); + if (encryptedResponse) { + body.set("response", encryptedResponse); + } else { + body.set("vp_token", String(presentation)); + } + body.set( + "presentation_submission", + JSON.stringify({ + id: definition.id, + definition_id: definition.id, + descriptor_map: [], + }), + ); + + const res = await this.fetchUtils.fetch(String(auth.responseUri), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `HttpOid4vpClient: response_uri POST failed (${res.status}): ${text.slice(0, 200)}`, + ); + } + submittedToResponseUri = true; + } + + return { + presentation, + format: credential.format, + disclosedClaims: built.disclosedClaims, + encryptedResponse, + responseMode, + submittedToResponseUri, + }; + } + + private normalizeAuthRequest( + raw: RawAuthRequest, + requestUrl: string, + ): Oid4vpAuthorizationRequest { + const clientId = raw.client_id ?? new URL(requestUrl).origin; + let presentationDefinition: PresentationDefinition; + + if (raw.dcql_query) { + const mapped = mapDcqlToPresentationFields(raw.dcql_query); + let verifierName = "Verifier"; + try { + verifierName = + raw.client_metadata?.client_name ?? new URL(clientId).hostname; + } catch { + verifierName = raw.client_metadata?.client_name ?? clientId; + } + presentationDefinition = { + id: raw.dcql_query.credentials[0]?.id ?? "dcql-request", + verifier: { + id: UriString(clientId), + name: verifierName, + }, + requestedClaims: mapped.requestedClaims, + credentialTypes: mapped.credentialTypes, + nonce: raw.nonce, + audience: clientId, + }; + } else if (raw.presentation_definition) { + const pd = raw.presentation_definition; + presentationDefinition = { + id: pd.id ?? "presentation-request", + verifier: pd.verifier + ? { + id: UriString(pd.verifier.id), + name: pd.verifier.name, + } + : { + id: UriString(clientId), + name: raw.client_metadata?.client_name ?? "Verifier", + }, + requestedClaims: (pd.requested_claims ?? []).map((c) => + CredentialClaimName(c), + ), + credentialTypes: (pd.credential_types ?? []).map((t) => + CredentialTypeName(t), + ), + nonce: pd.nonce ?? raw.nonce, + audience: pd.audience ?? clientId, + }; + } else { + throw new Error( + "OID4VP request must include dcql_query or presentation_definition", + ); + } + + const mode = raw.response_mode; + const responseMode = + mode === "direct_post" || + mode === "direct_post.jwt" || + mode === "fragment" + ? mode + : undefined; + + return { + clientId, + nonce: raw.nonce ?? presentationDefinition.nonce, + responseUri: raw.response_uri + ? UriString(raw.response_uri) + : undefined, + responseMode, + clientMetadata: raw.client_metadata, + dcqlQuery: raw.dcql_query, + presentationDefinition, + }; + } +} diff --git a/packages/ows-oid4/src/index.ts b/packages/ows-oid4/src/index.ts new file mode 100644 index 0000000..3e1d420 --- /dev/null +++ b/packages/ows-oid4/src/index.ts @@ -0,0 +1,31 @@ +export { HttpOid4vciClient } from "./http-oid4vci-client.js"; +export { HttpOid4vpClient } from "./http-oid4vp-client.js"; +export { FetchUtils, type FetchLike, type IFetchUtils } from "./fetch-json.js"; +export { + ParseUtils, + type IParseUtils, + type ParsedCredentialOfferUri, +} from "./parse-utils.js"; +export { mapDcqlToPresentationFields } from "./dcql-mapper.js"; +export { encryptPresentationResponse } from "./jwe.js"; +export { + DemoWalletAttestationProvider, + type DemoWalletAttestationOptions, +} from "./attestation.js"; +export { + storedCredentialFromSdJwtVc, + peekSdJwtVcPayload, +} from "./sd-jwt-stored.js"; +export { + CredentialsHelper, + buildOfferApprovalRequest, + buildPresentationApprovalRequest, + createCredentialsHolderSigner, + issueCredentialAfterApproval, + presentCredentialAfterApproval, + type ApproveAndAcceptOfferRequest, + type ApproveAndPresentRequest, + type CredentialsHelperOptions, + type CredentialsHelperWallet, + type CredentialsHelperDisplaySession, +} from "./credentials/credentials-helper.js"; diff --git a/packages/ows-oid4/src/jwe.ts b/packages/ows-oid4/src/jwe.ts new file mode 100644 index 0000000..15b888d --- /dev/null +++ b/packages/ows-oid4/src/jwe.ts @@ -0,0 +1,24 @@ +import * as jose from "jose"; + +const DEFAULT_ALG = "ECDH-ES"; +const DEFAULT_ENC = "A256GCM"; + +/** + * Encrypt a presentation string as a JWE Compact Serialization for + * `direct_post.jwt` using the verifier's encryption JWK. + */ +export async function encryptPresentationResponse( + plaintext: string, + recipientJwk: JsonWebKey, + options?: { + alg?: string; + enc?: string; + }, +): Promise { + const alg = options?.alg ?? DEFAULT_ALG; + const enc = options?.enc ?? DEFAULT_ENC; + const key = await jose.importJWK(recipientJwk, alg); + return new jose.CompactEncrypt(new TextEncoder().encode(plaintext)) + .setProtectedHeader({ alg, enc, cty: "JWT" }) + .encrypt(key); +} diff --git a/packages/ows-oid4/src/parse-utils.ts b/packages/ows-oid4/src/parse-utils.ts new file mode 100644 index 0000000..c6506a4 --- /dev/null +++ b/packages/ows-oid4/src/parse-utils.ts @@ -0,0 +1,82 @@ +import { + CredentialConfigurationId, + CredentialIssuer, + CredentialOfferUri, + type CredentialOffer, + type CredentialOfferGrants, +} from "@1shotapi/ows-types"; + +export type ParsedCredentialOfferUri = + | { kind: "https"; url: string } + | { kind: "openid-credential-offer"; query: URLSearchParams }; + +type RawOffer = { + credential_issuer: string; + credential_configuration_ids: string[]; + grants?: CredentialOfferGrants; +}; + +/** + * Credential offer URI parsing and normalization helpers. + */ +export interface IParseUtils { + parseCredentialOfferUri( + uri: CredentialOfferUri | string, + ): ParsedCredentialOfferUri; + normalizeCredentialOffer(raw: RawOffer): CredentialOffer; + offerFromQueryParams(params: URLSearchParams): { + offer?: CredentialOffer; + credentialOfferUri?: string; + }; +} + +/** + * Default {@link IParseUtils} for OID4VCI offer URIs and deep links. + */ +export class ParseUtils implements IParseUtils { + /** Parse `https://…` offer URLs and `openid-credential-offer://` deep links. */ + parseCredentialOfferUri( + uri: CredentialOfferUri | string, + ): ParsedCredentialOfferUri { + const text = String(uri); + if (text.startsWith("openid-credential-offer://")) { + const qIndex = text.indexOf("?"); + const query = new URLSearchParams( + qIndex >= 0 ? text.slice(qIndex + 1) : "", + ); + return { kind: "openid-credential-offer", query }; + } + if (text.startsWith("https://") || text.startsWith("http://")) { + return { kind: "https", url: text }; + } + throw new Error(`Unsupported credential offer URI scheme: ${text}`); + } + + normalizeCredentialOffer(raw: RawOffer): CredentialOffer { + return { + credentialIssuer: CredentialIssuer(raw.credential_issuer), + credentialConfigurationIds: raw.credential_configuration_ids.map((id) => + CredentialConfigurationId(id), + ), + grants: raw.grants, + }; + } + + offerFromQueryParams(params: URLSearchParams): { + offer?: CredentialOffer; + credentialOfferUri?: string; + } { + const byValue = params.get("credential_offer"); + if (byValue) { + const raw = JSON.parse(byValue) as RawOffer; + return { offer: this.normalizeCredentialOffer(raw) }; + } + const byUri = params.get("credential_offer_uri"); + if (byUri) { + return { credentialOfferUri: byUri }; + } + throw new Error( + "openid-credential-offer URI requires credential_offer or credential_offer_uri", + ); + } +} diff --git a/packages/ows-oid4/src/sd-jwt-stored.ts b/packages/ows-oid4/src/sd-jwt-stored.ts new file mode 100644 index 0000000..aba7b12 --- /dev/null +++ b/packages/ows-oid4/src/sd-jwt-stored.ts @@ -0,0 +1,75 @@ +import { + ConversionUtils, + CredentialId, + CredentialIssuer, + CredentialTypeName, + ISO8601DateTime, + UriString, + PresentationUtils, + type StoredCredential, +} from "@1shotapi/ows-types"; + +/** Peek at the issuer-signed JWT payload inside an SD-JWT VC (no verify). */ +export function peekSdJwtVcPayload(sdJwt: string): Record { + const issuerSigned = sdJwt.split("~")[0] ?? sdJwt; + const parts = issuerSigned.split("."); + if (parts.length < 2) { + throw new Error("Invalid SD-JWT VC"); + } + return ConversionUtils.decodeJsonBase64Url>( + parts[1]!, + ); +} + +/** + * Map an OID4VCI credential endpoint SD-JWT into {@link StoredCredential}. + * + * Subject claims include selectively disclosed fields unpacked from `~` segments. + */ +export async function storedCredentialFromSdJwtVc(input: { + sdJwt: string; + fallbackIssuer: CredentialIssuer; +}): Promise { + const payload = peekSdJwtVcPayload(input.sdJwt); + const iss = + typeof payload.iss === "string" + ? CredentialIssuer(payload.iss) + : input.fallbackIssuer; + const vct = typeof payload.vct === "string" ? payload.vct : "Credential"; + const iat = + typeof payload.iat === "number" + ? ISO8601DateTime(new Date(payload.iat * 1000).toISOString()) + : ISO8601DateTime(new Date().toISOString()); + const exp = + typeof payload.exp === "number" + ? ISO8601DateTime(new Date(payload.exp * 1000).toISOString()) + : undefined; + + const subject = await PresentationUtils.unpackSubject(input.sdJwt); + + const type = [ + CredentialTypeName("VerifiableCredential"), + CredentialTypeName(vct), + ]; + + return { + credentialId: CredentialId(`cred_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`), + format: "sd-jwt-vc", + type, + issuer: iss, + issuedAt: iat, + validUntil: exp, + payload: input.sdJwt, + semantic: { + type, + issuer: iss, + validFrom: iat, + validUntil: exp, + credentialSubject: subject, + credentialSchema: { + id: UriString(`https://schemas.ows.example/${vct}/v1`), + type: "JsonSchema", + }, + }, + }; +} diff --git a/packages/ows-oid4/test/credentials-helper.test.ts b/packages/ows-oid4/test/credentials-helper.test.ts new file mode 100644 index 0000000..37f6753 --- /dev/null +++ b/packages/ows-oid4/test/credentials-helper.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + CredentialConfigurationId, + CredentialFormatId, + CredentialId, + CredentialIssuer, + CredentialTypeName, + type CredentialOffer, + type CredentialSummary, + type IOid4vciClient, + type IOid4vpClient, + type IIssuerTrustRegistry, + type ICredentialRepository, + type IssuerMetadata, + type PresentationDefinition, + type StoredCredential, +} from "@1shotapi/ows-types"; +import { CredentialsHelper } from "../src/credentials/credentials-helper.js"; + +const issuer = CredentialIssuer("https://issuer.example"); + +function demoOffer(): CredentialOffer { + return { + credentialIssuer: issuer, + credentialConfigurationIds: [ + CredentialConfigurationId("KycCredential"), + ], + }; +} + +function demoMetadata(): IssuerMetadata { + return { + credentialIssuer: issuer, + credentialConfigurationsSupported: { + [CredentialConfigurationId("KycCredential")]: { + format: CredentialFormatId("sd-jwt-vc"), + }, + }, + }; +} + +describe("CredentialsHelper display", () => { + it("releases display after approveAndAcceptOffer (does not hide)", async () => { + let releaseCount = 0; + let hideCount = 0; + let approved = false; + + const wallet = { + requestDisplay: async () => ({ + hide: async () => { + hideCount += 1; + }, + release: () => { + releaseCount += 1; + }, + }), + credentials: { register: () => undefined }, + }; + + const oid4vci = { + resolveOffer: async () => demoOffer(), + fetchIssuerMetadata: async () => demoMetadata(), + } as unknown as IOid4vciClient; + + const trust: IIssuerTrustRegistry = { + isTrustedIssuer: async () => true, + }; + + const helper = new CredentialsHelper(wallet, { + repository: { + list: async () => [], + get: async () => undefined, + store: async () => undefined, + delete: async () => undefined, + } as ICredentialRepository, + oid4vci, + oid4vp: {} as IOid4vpClient, + trust, + approveAndAcceptOffer: async () => { + approved = true; + return { + credentialId: CredentialId("cred-1"), + format: CredentialFormatId("sd-jwt-vc"), + type: [CredentialTypeName("KycCredential")], + }; + }, + approveAndPresent: async () => { + throw new Error("unused"); + }, + }); + + const receipt = await helper.acceptOffer({ offer: demoOffer() }); + assert.equal(String(receipt.credentialId), "cred-1"); + assert.equal(approved, true); + assert.equal(releaseCount, 1); + assert.equal(hideCount, 0); + }); + + it("calls approveAndPresent with loaded credential and releases", async () => { + let releaseCount = 0; + const match: CredentialSummary = { + credentialId: CredentialId("cred-1"), + format: CredentialFormatId("sd-jwt-vc"), + type: [CredentialTypeName("KycCredential")], + issuer, + issuedAt: 0, + }; + const credential = { + credentialId: match.credentialId, + format: match.format, + type: match.type, + issuer, + } as StoredCredential; + + const definition = { + id: "def-1", + verifier: { name: "Demo Verifier", id: "https://verifier.example" }, + requestedClaims: [], + } as unknown as PresentationDefinition; + + const wallet = { + requestDisplay: async () => ({ + hide: async () => undefined, + release: () => { + releaseCount += 1; + }, + }), + credentials: { register: () => undefined }, + }; + + const oid4vp = { + resolveRequest: async () => definition, + matchCredentials: async () => [match], + } as unknown as IOid4vpClient; + + let gotCredentialId: string | undefined; + const helper = new CredentialsHelper(wallet, { + repository: { + list: async () => [match], + get: async () => credential, + store: async () => undefined, + delete: async () => undefined, + } as ICredentialRepository, + oid4vci: {} as IOid4vciClient, + oid4vp, + trust: { isTrustedIssuer: async () => true }, + recheckTrustOnPresent: false, + approveAndAcceptOffer: async () => { + throw new Error("unused"); + }, + approveAndPresent: async (request) => { + gotCredentialId = String(request.credential.credentialId); + return { + presentation: "fake.jwt", + submittedToResponseUri: false, + }; + }, + }); + + const result = await helper.present({ request: definition }); + assert.equal(result.presentation, "fake.jwt"); + assert.equal(gotCredentialId, "cred-1"); + assert.equal(releaseCount, 1); + }); +}); diff --git a/packages/ows-oid4/test/oid4.test.ts b/packages/ows-oid4/test/oid4.test.ts new file mode 100644 index 0000000..40d6034 --- /dev/null +++ b/packages/ows-oid4/test/oid4.test.ts @@ -0,0 +1,199 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mapDcqlToPresentationFields } from "../src/dcql-mapper.js"; +import { ParseUtils } from "../src/parse-utils.js"; +import { + encryptPresentationResponse, +} from "../src/jwe.js"; +import { CredentialOfferUri } from "@1shotapi/ows-types"; + +const parseUtils = new ParseUtils(); + +describe("dcql-mapper", () => { + it("maps a single credential claim query", () => { + const result = mapDcqlToPresentationFields({ + credentials: [ + { + id: "kyc", + format: "vc+sd-jwt", + meta: { vct_values: ["KycCredential"] }, + claims: [{ path: ["ageOver18"] }, { path: ["country"] }], + }, + ], + }); + assert.equal(result.requestedClaims.length, 2); + assert.equal(String(result.credentialTypes[0]), "KycCredential"); + }); + + it("rejects multi-credential queries", () => { + assert.throws(() => + mapDcqlToPresentationFields({ + credentials: [ + { id: "a", format: "vc+sd-jwt", claims: [{ path: ["x"] }] }, + { id: "b", format: "vc+sd-jwt", claims: [{ path: ["y"] }] }, + ], + }), + ); + }); +}); + +describe("ParseUtils", () => { + it("parses https and openid-credential-offer URIs", () => { + const https = parseUtils.parseCredentialOfferUri( + CredentialOfferUri("https://issuer.example/offers/demo"), + ); + assert.equal(https.kind, "https"); + + const deep = parseUtils.parseCredentialOfferUri( + CredentialOfferUri( + "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.example%2Foffers%2Fdemo", + ), + ); + assert.equal(deep.kind, "openid-credential-offer"); + }); + + it("normalizes raw offers", () => { + const offer = parseUtils.normalizeCredentialOffer({ + credential_issuer: "https://issuer.example", + credential_configuration_ids: ["KycCredential"], + grants: { + "urn:ietf:params:oauth:grant-type:pre-authorized_code": { + "pre-authorized_code": "code-1", + }, + }, + }); + assert.equal(String(offer.credentialIssuer), "https://issuer.example"); + assert.ok( + offer.grants?.["urn:ietf:params:oauth:grant-type:pre-authorized_code"]?.[ + "pre-authorized_code" + ], + ); + }); +}); + +describe("jwe", () => { + it("encrypts a presentation for a verifier public JWK", async () => { + const { generateKeyPair, exportJWK, compactDecrypt, importJWK } = + await import("jose"); + const { publicKey, privateKey } = await generateKeyPair("ECDH-ES", { + crv: "P-256", + extractable: true, + }); + const publicJwk = await exportJWK(publicKey); + publicJwk.alg = "ECDH-ES"; + const privateJwk = await exportJWK(privateKey); + const jwe = await encryptPresentationResponse("vp-token-payload", publicJwk); + const { plaintext } = await compactDecrypt( + jwe, + await importJWK(privateJwk, "ECDH-ES"), + ); + assert.equal(new TextDecoder().decode(plaintext), "vp-token-payload"); + }); +}); + +describe("HttpOid4vciClient", () => { + it("resolves offer, well-known, token prepare with mocked fetch", async () => { + const { HttpOid4vciClient } = await import("../src/http-oid4vci-client.js"); + const { FetchUtils } = await import("../src/fetch-json.js"); + const { CredentialIssuer } = await import("@1shotapi/ows-types"); + + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input); + if (url.endsWith("/offers/demo")) { + return new Response( + JSON.stringify({ + credential_issuer: "https://issuer.example", + credential_configuration_ids: ["KycCredential"], + grants: { + "urn:ietf:params:oauth:grant-type:pre-authorized_code": { + "pre-authorized_code": "code-1", + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url.includes("/.well-known/openid-credential-issuer")) { + return new Response( + JSON.stringify({ + credential_issuer: "https://issuer.example", + credential_endpoint: "https://issuer.example/credential", + token_endpoint: "https://issuer.example/token", + credential_configurations_supported: { + KycCredential: { format: "vc+sd-jwt", scope: "kyc" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url.endsWith("/token") && init?.method === "POST") { + return new Response( + JSON.stringify({ + access_token: "atk_test", + c_nonce: "cnonce_test", + c_nonce_expires_in: 300, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }; + + const client = new HttpOid4vciClient(new FetchUtils(fetchImpl), parseUtils); + const offer = await client.resolveOffer( + CredentialOfferUri("https://issuer.example/offers/demo"), + ); + assert.equal(String(offer.credentialIssuer), "https://issuer.example"); + + const metadata = await client.fetchIssuerMetadata( + CredentialIssuer("https://issuer.example"), + ); + assert.equal(String(metadata.tokenEndpoint), "https://issuer.example/token"); + + const prep = await client.prepareCredentialRequest!(offer, metadata); + assert.equal(prep.accessToken, "atk_test"); + assert.equal(prep.cNonce, "cnonce_test"); + }); +}); + +describe("HttpOid4vpClient", () => { + it("maps DCQL request_uri with mocked fetch", async () => { + const { HttpOid4vpClient } = await import("../src/http-oid4vp-client.js"); + const { FetchUtils } = await import("../src/fetch-json.js"); + const { PresentationRequestUri } = await import("@1shotapi/ows-types"); + + const fetchImpl: typeof fetch = async (input) => { + const url = String(input); + if (url.endsWith("/request/demo")) { + return new Response( + JSON.stringify({ + client_id: "https://verifier.example", + nonce: "vp-nonce-1", + response_uri: "https://verifier.example/response", + response_mode: "direct_post", + dcql_query: { + credentials: [ + { + id: "kyc", + format: "vc+sd-jwt", + meta: { vct_values: ["KycCredential"] }, + claims: [{ path: ["ageOver18"] }], + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }; + + const client = new HttpOid4vpClient(new FetchUtils(fetchImpl)); + const definition = await client.resolveRequest( + PresentationRequestUri("https://verifier.example/request/demo"), + ); + assert.equal(definition.nonce, "vp-nonce-1"); + assert.equal(String(definition.requestedClaims[0]), "ageOver18"); + assert.equal(String(definition.credentialTypes?.[0]), "KycCredential"); + }); +}); diff --git a/packages/ows-oid4/tsconfig.json b/packages/ows-oid4/tsconfig.json new file mode 100644 index 0000000..a013e0c --- /dev/null +++ b/packages/ows-oid4/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/ows-onchain/README.md b/packages/ows-onchain/README.md new file mode 100644 index 0000000..f9f3ce9 --- /dev/null +++ b/packages/ows-onchain/README.md @@ -0,0 +1,44 @@ +# @1shotapi/ows-onchain + +EIP-8244 build and deploy pipeline for publishing `@1shotapi/ows-signer` on-chain as part of the **Open Wallet Standard (OWS)**. + +## Pipeline overview + +Adapted from the [EIP-8244 template](https://github.com/TtheBC01/eip-8244): + +1. **Input** — `../ows-signer/html/index.html` (plain JS, no build step). +2. **Compress** — minify → gzip (level 9) → base64 encode. +3. **Store** — SSTORE2-style data contract bytecode; main contract holds an immutable pointer. +4. **Serve** — `html()` returns a bootstrap document that gunzips client-side. +5. **Gateway** — optional HTTP server for local dev and ERC-4804 / ERC-5219 clients. + +``` +ows-signer/html/index.html + │ + ▼ minify → gzip → base64 + │ + ▼ codegen → contracts/OwsSignerFrontend.sol (generated) + │ + ▼ Hardhat Ignition deploy + │ + ▼ html() → browser gunzip → OWS Signing Layer runs +``` + +## Local development + +```bash +# Terminal 1 — local chain +npm run node -w @1shotapi/ows-onchain + +# Terminal 2 — build, deploy, serve +npm run deploy:local -w @1shotapi/ows-onchain +RPC_URL=http://127.0.0.1:8545 npm run serve -w @1shotapi/ows-onchain +``` + +## Generated artifacts + +`contracts/OwsSignerFrontend.sol` is **generated** by `npm run build:html`. Do not edit by hand. + +## Status + +Scaffold only — build scripts forthcoming (ported from eip-8244). diff --git a/packages/ows-onchain/contracts/OwsSignerFrontend.sol b/packages/ows-onchain/contracts/OwsSignerFrontend.sol new file mode 100644 index 0000000..f62ae90 --- /dev/null +++ b/packages/ows-onchain/contracts/OwsSignerFrontend.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/** + * @dev Generated by scripts/build.ts from @1shotapi/ows-signer HTML. + * Do not edit by hand — run `npm run build:html -w @1shotapi/ows-onchain`. + */ +contract OwsSignerFrontend { + function html() external pure returns (string memory) { + return ""; + } +} diff --git a/packages/ows-onchain/hardhat.config.ts b/packages/ows-onchain/hardhat.config.ts new file mode 100644 index 0000000..23d2d18 --- /dev/null +++ b/packages/ows-onchain/hardhat.config.ts @@ -0,0 +1,42 @@ +import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem"; +import { configVariable, defineConfig } from "hardhat/config"; + +export default defineConfig({ + plugins: [hardhatToolboxViemPlugin], + solidity: { + profiles: { + default: { + version: "0.8.28", + }, + production: { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + }, + }, + networks: { + hardhatMainnet: { + type: "edr-simulated", + chainType: "l1", + }, + localhost: { + type: "http", + chainType: "l1", + url: "http://127.0.0.1:8545", + accounts: [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ], + }, + sepolia: { + type: "http", + chainType: "l1", + url: configVariable("SEPOLIA_RPC_URL"), + accounts: [configVariable("SEPOLIA_PRIVATE_KEY")], + }, + }, +}); diff --git a/packages/ows-onchain/ignition/modules/OwsSignerFrontend.ts b/packages/ows-onchain/ignition/modules/OwsSignerFrontend.ts new file mode 100644 index 0000000..d9e9170 --- /dev/null +++ b/packages/ows-onchain/ignition/modules/OwsSignerFrontend.ts @@ -0,0 +1,6 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; + +export default buildModule("OwsSignerFrontendModule", (m) => { + const frontend = m.contract("OwsSignerFrontend"); + return { frontend }; +}); diff --git a/packages/ows-onchain/package.json b/packages/ows-onchain/package.json new file mode 100644 index 0000000..32f1c06 --- /dev/null +++ b/packages/ows-onchain/package.json @@ -0,0 +1,50 @@ +{ + "name": "@1shotapi/ows-onchain", + "version": "0.0.0", + "private": true, + "description": "OWS EIP-8244 build and deploy pipeline for publishing the Signing Layer on-chain", + "type": "module", + "files": [ + "contracts", + "ignition", + "scripts", + "README.md" + ], + "scripts": { + "build:html": "node scripts/build.ts", + "compile": "hardhat compile", + "deploy:local": "npm run build:html && hardhat compile && hardhat ignition deploy ignition/modules/OwsSignerFrontend.ts --network localhost", + "deploy:sepolia": "npm run build:html && hardhat compile --build-profile production && hardhat ignition deploy ignition/modules/OwsSignerFrontend.ts --network sepolia", + "serve": "node scripts/serve.ts", + "node": "hardhat node", + "test": "node --test test/*.test.js", + "test:hardhat": "hardhat test", + "clean": "node -e \"require('fs').rmSync('cache',{recursive:true,force:true}); require('fs').rmSync('artifacts',{recursive:true,force:true})\"" + }, + "dependencies": { + "@1shotapi/ows-signer": "*" + }, + "devDependencies": { + "@nomicfoundation/hardhat-ignition": "^3.1.7", + "@nomicfoundation/hardhat-toolbox-viem": "^5.0.7", + "@types/node": "^22.19.0", + "forge-std": "github:foundry-rs/forge-std#v1.9.4", + "hardhat": "^3.9.0", + "viem": "^2.52.0" + }, + "keywords": [ + "ows", + "open-wallet-standard", + "eip-8244", + "onchain", + "hardhat", + "webauthn", + "prf" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-onchain" + }, + "license": "MIT" +} diff --git a/packages/ows-onchain/scripts/build.ts b/packages/ows-onchain/scripts/build.ts new file mode 100644 index 0000000..674ceb4 --- /dev/null +++ b/packages/ows-onchain/scripts/build.ts @@ -0,0 +1,10 @@ +/** + * Reads ows-signer HTML, compresses it, and generates OwsSignerFrontend.sol. + * + * Ported from https://github.com/TtheBC01/eip-8244 — implementation forthcoming. + */ + +console.error( + "@1shotapi/ows-onchain build:html — not yet implemented. See packages/ows-onchain/README.md.", +); +process.exit(1); diff --git a/packages/ows-onchain/scripts/serve.ts b/packages/ows-onchain/scripts/serve.ts new file mode 100644 index 0000000..cdb67c2 --- /dev/null +++ b/packages/ows-onchain/scripts/serve.ts @@ -0,0 +1,10 @@ +/** + * Local HTTP server that reads html() from the deployed OWS signer contract. + * + * Ported from https://github.com/TtheBC01/eip-8244 — implementation forthcoming. + */ + +console.error( + "@1shotapi/ows-onchain serve — not yet implemented. See packages/ows-onchain/README.md.", +); +process.exit(1); diff --git a/packages/ows-onchain/test/scaffold.test.js b/packages/ows-onchain/test/scaffold.test.js new file mode 100644 index 0000000..9f68b01 --- /dev/null +++ b/packages/ows-onchain/test/scaffold.test.js @@ -0,0 +1,10 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +describe("@1shotapi/ows-onchain", () => { + it("scaffold placeholder", () => { + assert.ok(true); + }); +}); + +// Hardhat contract tests: npm run test:hardhat -w @1shotapi/ows-onchain diff --git a/packages/ows-onchain/tsconfig.json b/packages/ows-onchain/tsconfig.json new file mode 100644 index 0000000..e96364a --- /dev/null +++ b/packages/ows-onchain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["scripts/**/*", "test/**/*", "hardhat.config.ts"] +} diff --git a/packages/ows-provider/CHANGELOG.md b/packages/ows-provider/CHANGELOG.md new file mode 100644 index 0000000..bbeb0b0 --- /dev/null +++ b/packages/ows-provider/CHANGELOG.md @@ -0,0 +1,152 @@ +# @1shotapi/ows-provider + +## 0.8.0 + +### Minor Changes + +- 17fe471: **ows-provider:** Add Wallet Standard discovery on `proxy.solana.registerSolanaWallet()` / `unregisterSolanaWallet()`, forwarding connect and Solana features to the existing Branding wire. Host-side `@solana/transactions` helpers split/merge full serialized transactions for `signTransaction` / `signAndSendTransaction`. + + **ows-types:** Solana Phase 2 primitives and utils for Branding/Host: + + - Add `SolanaATAAddress` + `SolanaAddressUtils.asAtaAddress` + - Rename `SolanaTransactionSignature` → `SolanaTransactionHash` (breaking rename; use the new export) + - Add `TransactionHash` union (`EVMTransactionHash | SolanaTransactionHash`) and `parseTransactionHash` + - Add `ConversionUtils.bytesToBase64` / `base64ToBytes` (standard base64 as `Base64String`) for Host↔Branding wire payloads + +### Patch Changes + +- Updated dependencies [17fe471] + - @1shotapi/ows-types@0.15.0 + +## 0.7.0 + +### Minor Changes + +- 972fd3b: Solana Phase 1: CAIP-2 `SolanaChainId` (`solana:mainnet` / `devnet` / `testnet`), branded program/mint/signature primitives, `SolanaSigner.signMessage` / `signTransaction` + Kit partial signer, and Host/Branding `solana.*` wire methods (`proxy.solana` / `wallet.solana.register`). + +### Patch Changes + +- Updated dependencies [972fd3b] + - @1shotapi/ows-types@0.14.0 + +## 0.6.0 + +### Minor Changes + +- 88a9f66: Remove RPC timeouts across Host and Signing layers. `RpcHostClient` (ows-provider) and `SignerRpcClient` (ows-signer-utils) no longer impose a 120s deadline — user-paced requests (`personal_sign`, `wallet_requestExecutionPermissions`, signer Confirm / WebAuthn, …) stay pending until the other layer responds, or reject on destroy / session clear. Removed: `OWSProxyOptions.rpcTimeoutMs`, `OWSSignerOptions.rpcTimeoutMs`, `RequestOptions.timeoutMs`, `DEFAULT_RPC_TIMEOUT_MS`, `OwsRpcTimeoutError`, and `OwsTimeoutError`. Hosts that need a bound on a non-interactive call should race their own deadline at the call site. + +### Patch Changes + +- Updated dependencies [88a9f66] + - @1shotapi/ows-types@0.13.0 + +## 0.5.1 + +### Patch Changes + +- 042beab: Bitcoin chain ids are now descriptive string sentinels (`"Bitcoin"`, `"BitcoinTestnet"`) instead of numeric `-1`/`-2`. Add `ChainUtils` for narrowing `OWSChainId` across EVM, Bitcoin, and Solana, plus `SolanaChainId` sentinels. +- Updated dependencies [042beab] + - @1shotapi/ows-types@0.8.0 + +## 0.5.0 + +### Minor Changes + +- 728eb8d: Add Bitcoin Native SegWit (P2WPKH) support: + - `ows-types`: Replace `BitcoinAccountAddress` with `BitcoinSegwitAccountAddress`. Add `BitcoinSignatureHex`, `BitcoinTransactionHash`, `BitcoinChainId` (-1 mainnet, -2 testnet), `OWSChainId`, BIP-122 static wallet types, and dual-network Bitcoin cache methods on `IOWSSigner`. + - `ows-signer-utils`: Add `BitcoinSigner` (`signer.bitcoin`), SegWit address derivation (`addressFromSecp256k1PublicKey`), BIP-143 transaction marshalling, and dual-network caching. + - `ows-provider`: Add `BitcoinHostClient` (`proxy.bitcoin.getAccountAddresses`) conforming to Reown BIP-122 static-wallet pattern. + - `ows-wallet-utils`: Add `validateBitcoinSegwitAddress` with strict bech32 SegWit v0 validation, and `BitcoinWalletRegistrar` (`wallet.bitcoin.register`). + +### Patch Changes + +- Updated dependencies [728eb8d] + - @1shotapi/ows-types@0.7.0 + +## 0.4.2 + +### Patch Changes + +- 36fa253: Wire Branding→Host EIP-1193 notifications (`chainChanged`, `accountsChanged`) over Postmate so hosts stay in sync when the wallet changes chain or accounts. +- Updated dependencies [36fa253] + - @1shotapi/ows-types@0.5.1 + +## 0.4.1 + +### Patch Changes + +- f0eff1f: Release focus from the wallet iframe before collapsing it, and mark the host shell `inert` (then `aria-hidden`) so Chrome no longer warns when hiding after a WebAuthn ceremony. + +## 0.4.0 + +### Minor Changes + +- 09afc87: Add Branding→Host analytics wire (`ows:analytics`): `IOWSAnalyticsEvent` / abstract `OWSAnalyticsEvent` (branding subclasses) with index-signature extras, `OWSWallet.analytics.emit`, and `OWSProxy.analytics` EventEmitter. + +### Patch Changes + +- Updated dependencies [09afc87] + - @1shotapi/ows-types@0.4.0 + +## 0.3.1 + +### Patch Changes + +- 28dc576: Use width-only viewport checks for flyout vs drawer so short desktop windows keep the corner flyout. + +## 0.3.0 + +### Minor Changes + +- 2f6bb67: Host-owned wallet sizing: flyout vs full-screen drawer, no branding size requests. + + `requestDisplay` is a show session only (no width/height). On small viewports the host panel opens as a full-screen drawer with a bottom wipe; larger viewports keep the lower-right flyout at `walletSizeX`/`walletSizeY`. + +### Patch Changes + +- Updated dependencies [2f6bb67] + - @1shotapi/ows-types@0.2.4 + +## 0.2.1 + +### Patch Changes + +- 498d823: Replace SignHelper `requestSendTransactionApproval` with branding-owned `approveAndSignTransaction`; export `prepareEvmTransaction`. Allow `camera` on the branding iframe for optional QR address scan. +- Updated dependencies [498d823] + - @1shotapi/ows-types@0.1.3 + +## 0.2.0 + +### Minor Changes + +- 58ef5d1: Add create-time `presentationMode` (`flyout` | `inline`) for branding iframe chrome. Presentation is immutable — recreate the proxy to switch (no reparent/`setPresentation`; that breaks Postmate). Default visible size is 360×600. + +## 0.1.0 + +### Minor Changes + +- 0e1b1d1: Move SD-JWT VC presentation and crypto utilities from `ows-wallet-utils` into `ows-types` (`utils/credentials/`). Drop `ows-provider`'s dependency on `ows-wallet-utils`; host apps use `@1shotapi/ows-types` for presentation helpers and `@1shotapi/ows-provider` for verify/proxy only. + + **Breaking:** `buildSdJwtVcPresentation`, `extractHolderJwkFromSdJwtVc`, and crypto helpers are no longer exported from `@1shotapi/ows-wallet-utils` or re-exported from `@1shotapi/ows-provider`. Use `PresentationUtils` / `CredentialCryptoUtils` from `@1shotapi/ows-types`. `CredentialCryptoUtils.createOwsEd25519HolderSigner` lives on `@1shotapi/ows-types`. + +- aaf43d4: Add `OWSWallet.requestDisplay()` / `requestHide()` so the branding iframe can ask the host to show or hide a lower-right wallet flyout (opaque panel, no modal backdrop) before WebAuthn or approval UI in cross-origin embeds. Host-side `OWSProxy` handles `ows:requestDisplay`, `ows:requestHide`, and `ows:releaseDisplay`. +- e83e5ce: Fold verifiable credentials into core OWS packages: types and OID4 interfaces in `ows-types`, branding wire and SD-JWT presentation in `ows-wallet-utils`, host proxy and SD-JWT verify in `ows-provider`. Demo mocks live under `examples/shared` (not published). Removes `@1shotapi/ows-credentials`. + +### Patch Changes + +- 3c8b506: Add OID4VCI holder proof-of-possession via `ProofUtils` (`buildOid4vciProofJwt` / `verifyOid4vciProofJwt`, branded `JWKThumbprint`) and require proof in issuance context. Enforce `kb+jwt` audience during SD-JWT VC presentation verify. +- 89e40a9: Phase 3 credentials hardening: `ICredentialRepository`, `IIssuerTrustRegistry.isTrustedIssuer`, `CredentialsHelper`, host `acceptedIssuers` on present, fail-closed status checks, and stubbed `OWSSigner.encryptAES256` / `decryptAES256` with branded `AES256CipherText`. +- 9a87faf: Move shared EIP-1193 method lists and typed request tables into `@1shotapi/ows-types`. Add `RpcHelper` in `ows-wallet-utils` to register JSON-RPC read methods and chain switching on `OWSWallet` (replaces the registry rpc-provider module for branding apps). +- eae834a: Add `EVMChainId` and allow branding modules to register additional EIP-1193 methods (e.g. JSON-RPC reads) on the wallet Postmate model. +- Updated dependencies [0e1b1d1] +- Updated dependencies [aaf43d4] +- Updated dependencies [e83e5ce] +- Updated dependencies [3c8b506] +- Updated dependencies [89e40a9] +- Updated dependencies [a78bba8] +- Updated dependencies [9a87faf] +- Updated dependencies [eae834a] +- Updated dependencies [55f9a22] +- Updated dependencies [aaf43d4] +- Updated dependencies [8eeecf2] + - @1shotapi/ows-types@0.1.0 diff --git a/packages/ows-provider/README.md b/packages/ows-provider/README.md new file mode 100644 index 0000000..0da3e0e --- /dev/null +++ b/packages/ows-provider/README.md @@ -0,0 +1,143 @@ +# @1shotapi/ows-provider + +EIP-1193 provider for **Host Layer** applications using an OWS-compatible Branding Layer iframe. + +Embed your branding iframe, get `proxy.ethereum` for viem/ethers/wagmi, and call custom wallet RPC via `proxy.rpc()`. + +## Install + +```bash +npm install @1shotapi/ows-provider +``` + +Your host app must also have a Branding Layer iframe running [`OWSWallet`](https://github.com/1Shot-API/open-wallet/tree/main/packages/ows-wallet-utils) from `@1shotapi/ows-wallet-utils`. + +For SD-JWT VC presentation building and crypto helpers, import from `@1shotapi/ows-types`. Use `verifySdJwtVcPresentation` from this package for host-side verification. + +## Quick start + +```typescript +import { OWSProxy } from "@1shotapi/ows-provider"; +import { createWalletClient, custom } from "viem"; + +const container = document.getElementById("wallet")!; +const proxy = await OWSProxy.create(container, "https://wallet.example.com"); + +const accounts = await proxy.ethereum.request({ method: "eth_requestAccounts" }); + +const client = createWalletClient({ + transport: custom(proxy.ethereum), +}); +``` + +### Custom wallet RPC + +```typescript +const status = await proxy.rpc<{ connected: boolean }>("getStatus"); +``` + +Returns an `OwsUnimplementedError` (from `@1shotapi/ows-types`) if the Branding Layer did not register that method. + +### Analytics (`proxy.analytics`) + +Branding publishes product events over Postmate (`ows:analytics`). OWS types only the required base fields (`eventId`, `timestamp`, `hostDomain`, `name`); branding may attach additional properties. Hosts receive the **full** object and can narrow on `name` (per branding docs). + +```typescript +proxy.analytics.on((event) => { + // event.name, event.hostDomain, event.timestamp, event.eventId + extras + console.log(event); +}); + +proxy.analytics.on("TransactionSubmitted", (event) => { + // filter by branding-defined name string +}); +``` + +OWS does not ship Google Analytics or define a product event catalog. + +## API + +### `OWSProxy.create(container, walletUrl, options?)` + +| Option | Description | +|--------|-------------| +| `name` | iframe `name` attribute (default `ows-wallet`) | +| `classList` | CSS classes on iframe at creation | +| `walletSizeX` / `walletSizeY` | Preferred visible panel size in CSS pixels (default 360×600). Branding scales to this; it does not request its own size. | +| `presentationMode` | `flyout` (default) or `inline` — fixed for this proxy instance | +| `allowLocalAccess` | When true, add `local-network-access` / `loopback-network` to iframe `allow` (default `false`) | + +### Presentation (flyout / drawer vs inline) + +`ows-provider` styles the **host container** passed to `create()`. The iframe fills that container (`width/height: 100%`). **Never reparent the iframe after create** — Postmate messaging breaks. To switch presentation, `destroy()` and create a new proxy against the desired container. + +In **flyout** mode, if the host viewport is narrower than `walletSizeX + 32px` (16px margin on each side), the panel opens as a **full-screen drawer** with a bottom wipe open / wipe-down close. Otherwise it stays a fixed lower-right flyout at the configured size (no clamping). Short-but-wide desktop windows keep the flyout — drawer is for narrow / phone-like widths. + +```typescript +import { EWalletPresentationMode, OWSProxy } from "@1shotapi/ows-provider"; + +// Flyout or drawer (auto): collapsed until showWallet() +const flyout = await OWSProxy.create(flyoutSlot, walletUrl, { + presentationMode: EWalletPresentationMode.Flyout, + walletSizeX: 360, + walletSizeY: 600, +}); +flyout.showWallet(); + +// Inline: always visible, fills create() container (size the mount yourself) +const inline = await OWSProxy.create(previewSlot, walletUrl, { + presentationMode: EWalletPresentationMode.Inline, +}); +``` + +| Mode | Container | show / hide | +|------|-----------|-------------| +| `flyout` | Usually off-page / body child | Wide viewport → lower-right flyout; narrow → full-screen drawer wipe; hide collapses | +| `inline` | Page slot (sidebar, preview) | Show = fill; hide = no-op | + +RPC requests have **no provider-imposed timeout**: user-paced methods (`personal_sign`, `eth_sendTransaction`, `wallet_requestExecutionPermissions`, …) stay pending until the Branding Layer responds or `proxy.destroy()` rejects them. Hosts that need a bound on a specific non-interactive call should apply their own `Promise.race` / abort at the call site. + +### `proxy.ethereum` + +`EIP1193Provider` — EIP-1193 `request` with typed results for known methods (`eth_requestAccounts` → `EVMAccountAddress[]`, `personal_sign` → signature hex, etc.). Construct with `new EIP1193Provider(invoke)` or use `OWSProxy.create`, which wires RPC automatically. + +### `proxy.bitcoin` + +`BitcoinHostClient` — Reown / BIP-122 static-wallet pattern helper (`proxy.bitcoin.getAccountAddresses(params?)`). + +### `proxy.solana` + +`SolanaHostClient` — Solana Host↔Branding wire (`getAccountAddress`, `signMessage`, `signTransaction`, `signAndSendTransaction`, `signIn`). Branding must `wallet.solana.register(provider)` for these methods to resolve. + +Opt-in Wallet Standard discovery (never auto-registered in `OWSProxy.create`): + +```ts +import { getWallets } from "@wallet-standard/app"; + +proxy.solana.registerSolanaWallet(); // optional { name?, icon? } +console.log(getWallets().get()); // includes "1Shot Wallet" + +// later / on destroy: +proxy.solana.unregisterSolanaWallet(); +``` + +Advertised features: `standard:connect` / `disconnect` / `events`, `solana:signMessage`, `solana:signIn`, `solana:signTransaction`, `solana:signAndSendTransaction` on chains `solana:mainnet` and `solana:devnet`. Connect uses `getAccountAddress`. Signing features call the wire; unimplemented Branding handlers surface as RPC errors (features stay discoverable). Full serialized transactions are split/merged with `@solana/transactions` inside the Host Standard adapter — Branding still receives message bytes. + +### `proxy.rpc(method, params?)` + +Extension RPC for wallet-specific methods (non–EIP-1193). + +## Architecture + +``` +Host Layer — OWSProxy + └── Postmate iframe + └── Branding Layer — OWSWallet + └── Signing Layer — OWSSigner via ows-signer-utils +``` + +`OWSProxy` sets iframe Permissions Policy (`allow`) before navigation (WebAuthn + clipboard), including when using stock `postmate`. Pass `allowLocalAccess: true` when the branding layer must fetch LAN/loopback hosts (Chrome still prompts). + +## License + +MIT diff --git a/packages/ows-provider/package.json b/packages/ows-provider/package.json new file mode 100644 index 0000000..7300dfb --- /dev/null +++ b/packages/ows-provider/package.json @@ -0,0 +1,59 @@ +{ + "name": "@1shotapi/ows-provider", + "version": "0.8.0", + "description": "OWS EIP-1193 provider for Host Layer applications — embed Branding Layer iframe and use with viem, ethers, etc.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "node --import tsx --test test/*.test.ts", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@1shotapi/ows-types": "*", + "@scure/base": "^1.2.6", + "@sd-jwt/sd-jwt-vc": "^0.20.0", + "@solana/addresses": "^8.5.0", + "@solana/keys": "^8.5.0", + "@solana/transactions": "^8.5.0", + "@solana/wallet-standard-features": "^1.5.0", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/features": "^1.1.1", + "@wallet-standard/wallet": "^1.1.1", + "postmate": "^1.5.2" + }, + "devDependencies": { + "@types/postmate": "^1.5.4", + "tsx": "^4.19.0" + }, + "keywords": [ + "ows", + "open-wallet-standard", + "eip-1193", + "ethereum", + "provider", + "viem", + "ethers" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-provider" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ows-provider/src/OWSProxy.ts b/packages/ows-provider/src/OWSProxy.ts new file mode 100644 index 0000000..5a5505a --- /dev/null +++ b/packages/ows-provider/src/OWSProxy.ts @@ -0,0 +1,316 @@ +import Postmate from "postmate"; +import { RpcHostClient } from "./rpc/RpcHostClient.js"; +import { EIP1193Provider } from "./eip1193/EIP1193Provider.js"; +import { + applyHiddenWalletContainerStyles, + applyHiddenWalletFrameStyles, + DEFAULT_WALLET_SIZE_X, + DEFAULT_WALLET_SIZE_Y, + DisplayHostHandler, + EWalletPresentationMode, +} from "./display/DisplayHostHandler.js"; +import { CredentialHostClient } from "./credentials/CredentialsHostClient.js"; +import { BitcoinHostClient } from "./bitcoin/BitcoinHostClient.js"; +import { SolanaHostClient } from "./solana/SolanaHostClient.js"; +import { AnalyticsHostHandler } from "./analytics/AnalyticsHostHandler.js"; +import type { AnalyticsListener } from "./analytics/AnalyticsHostHandler.js"; +import { Eip1193EventHostHandler } from "./eip1193/Eip1193EventHostHandler.js"; + +export type OWSProxyOptions = { + /** iframe `name` attribute. Default: `ows-wallet` */ + name?: string; + /** CSS classes applied to the iframe at creation time. */ + classList?: string[]; + /** + * Preferred wallet panel width in CSS pixels (flyout mode). + * Default: {@link DEFAULT_WALLET_SIZE_X} (360). + * When the viewport is narrower than this width + 32px margin, the panel + * opens as a full-screen drawer instead. Branding scales to the iframe; + * it does not request its own size. + */ + walletSizeX?: number; + /** + * Preferred wallet panel height in CSS pixels (flyout mode). + * Default: {@link DEFAULT_WALLET_SIZE_Y} (600). + * Used for the flyout box size; drawer switching is width-based (see + * {@link OWSProxyOptions.walletSizeX}). + */ + walletSizeY?: number; + /** + * Presentation mode for this proxy (immutable). Default: flyout. + * Do not reparent the iframe after create — destroy and recreate with a + * different `container` / mode instead (Postmate breaks on reparent). + */ + presentationMode?: EWalletPresentationMode; + /** + * When true, grant Chrome Local Network Access / loopback Permissions Policy + * so the branding iframe may fetch hosts that resolve to the user's LAN or + * `127.0.0.1` (still requires a user permission prompt in Chrome 142+). + * Default: `false`. Credential issuer/verifier demos set this so the ngrok + * branding iframe can fetch local OID4 HTTPS endpoints (Chrome still prompts). + */ + allowLocalAccess?: boolean; +}; + +/** Base Permissions Policy for the branding iframe (must be set before navigation). */ +const WALLET_IFRAME_ALLOW_BASE = [ + "publickey-credentials-get *", + "publickey-credentials-create *", + // Branding-layer UI (e.g. create-backup copy) and delegation to the signer iframe. + "clipboard-write *", + // Address QR scan in Send UI (only used when permission already granted). + "camera *", +]; + +const WALLET_IFRAME_ALLOW_LOCAL = [ + "local-network-access *", + "loopback-network *", +]; + +function walletIframeAllow(allowLocalAccess: boolean): string { + return ( + allowLocalAccess + ? [...WALLET_IFRAME_ALLOW_BASE, ...WALLET_IFRAME_ALLOW_LOCAL] + : WALLET_IFRAME_ALLOW_BASE + ).join("; "); +} +/** Must match postmate's internal `messageType` (not exported). */ +const POSTMATE_MESSAGE_TYPE = "application/x-postmate-v1+json"; + +/** + * Postmate only retries the parent handshake 5× (~2.5s after iframe `load`). + * React branding apps often register `Postmate.Model` slightly later (or after + * slow work). Keep pulsing until Postmate resolves or this budget elapses. + */ +const EXTENDED_HANDSHAKE_INTERVAL_MS = 500; +const EXTENDED_HANDSHAKE_BUDGET_MS = 30_000; + +export class OWSProxy { + public readonly ethereum: EIP1193Provider; + public readonly credentials: CredentialHostClient; + public readonly bitcoin: BitcoinHostClient; + public readonly solana: SolanaHostClient; + + /** + * Branding→Host analytics notifications (`ows:analytics`). + * Events include the OWS base fields plus branding-owned rich properties. + */ + public readonly analytics: { + on(listener: AnalyticsListener): () => void; + on(name: string, listener: AnalyticsListener): () => void; + off(listener: AnalyticsListener): void; + }; + + private readonly rpcClient: RpcHostClient; + private readonly displayHandler: DisplayHostHandler; + private readonly analyticsHandler: AnalyticsHostHandler; + private readonly eip1193EventHandler: Eip1193EventHostHandler; + private readonly parent: Postmate.ParentAPI; + + private constructor( + parent: Postmate.ParentAPI, + rpcClient: RpcHostClient, + displayHandler: DisplayHostHandler, + analyticsHandler: AnalyticsHostHandler, + ) { + this.parent = parent; + this.rpcClient = rpcClient; + this.displayHandler = displayHandler; + this.analyticsHandler = analyticsHandler; + this.credentials = new CredentialHostClient(rpcClient); + this.bitcoin = new BitcoinHostClient(rpcClient); + this.solana = new SolanaHostClient(rpcClient); + this.ethereum = new EIP1193Provider((method, params) => + this.rpc(method, params), + ); + this.eip1193EventHandler = new Eip1193EventHostHandler( + parent, + this.ethereum, + ); + this.analytics = { + on: this.analyticsHandler.on.bind(this.analyticsHandler), + off: this.analyticsHandler.off.bind(this.analyticsHandler), + }; + } + + static async create( + container: HTMLElement, + walletUrl: string, + options?: OWSProxyOptions, + ): Promise { + if (typeof window === "undefined") { + throw new Error("OWSProxy requires a browser environment"); + } + + const presentationMode = + options?.presentationMode ?? EWalletPresentationMode.Flyout; + const inline = presentationMode === EWalletPresentationMode.Inline; + + // Flyout: collapse before handshake so content does not flash. + // Inline: leave the host mount alone — iframe stays in this container forever. + if (!inline) { + applyHiddenWalletContainerStyles(container); + } + + // Postmate sets a minimal `allow` then appendChild, then assigns `src`. + // Permissions Policy is fixed at navigation — patch allow on append, before src. + const parent = await withWalletIframeAllow( + container, + Boolean(options?.allowLocalAccess), + () => + withExtendedPostmateHandshake(container, walletUrl, () => + new Postmate({ + container, + url: walletUrl, + name: options?.name ?? "ows-wallet", + classListArray: options?.classList ?? [], + }), + ), + { hideFrameOnAppend: !inline }, + ); + + const displayHandler = new DisplayHostHandler(parent, { + walletSizeX: options?.walletSizeX ?? DEFAULT_WALLET_SIZE_X, + walletSizeY: options?.walletSizeY ?? DEFAULT_WALLET_SIZE_Y, + presentationMode, + }); + if (inline) { + displayHandler.initializeInlineVisible(); + } else { + displayHandler.initializeHidden(); + } + const rpcClient = new RpcHostClient(parent, { + beforeRequest: () => displayHandler.prepareForRpcAccess(), + afterRequest: () => displayHandler.completeRpcAccess(), + }); + const analyticsHandler = new AnalyticsHostHandler(parent); + + return new OWSProxy(parent, rpcClient, displayHandler, analyticsHandler); + } + + rpc(method: string, params?: unknown): Promise { + return this.rpcClient.request(method, params ?? null); + } + + /** + * Show the branding iframe (host-initiated). + * Flyout mode: lower-right panel, or full-screen drawer with bottom wipe when + * the viewport is narrower than {@link OWSProxyOptions.walletSizeX} + 32px. + * Inline: ensure the create() container is filled. + */ + showWallet(): void { + this.displayHandler.show(); + } + + /** + * Hide a host-initiated panel from {@link showWallet}. + * Drawer mode animates a wipe-down before collapse. + * No-op in inline presentation (embedded panel stays visible). + */ + hideWallet(): void { + this.displayHandler.hide(); + } + + destroy(): void { + this.solana.destroy(); + this.eip1193EventHandler.destroy(); + this.analyticsHandler.destroy(); + this.displayHandler.destroy(); + this.rpcClient.destroy(); + this.parent.destroy(); + } +} + +/** + * Postmate creates the iframe, sets a default `allow`, appends it, then sets `src`. + * Intercept `appendChild` so our Permissions Policy is in place before navigation. + */ +async function withWalletIframeAllow( + container: HTMLElement, + allowLocalAccess: boolean, + create: () => Promise, + options?: { hideFrameOnAppend?: boolean }, +): Promise { + const allow = walletIframeAllow(allowLocalAccess); + const hideFrameOnAppend = options?.hideFrameOnAppend !== false; + const originalAppend = container.appendChild.bind(container); + container.appendChild = ((node: T): T => { + if (node instanceof HTMLIFrameElement) { + node.allow = allow; + if (hideFrameOnAppend) { + applyHiddenWalletFrameStyles(node); + } + } + return originalAppend(node) as T; + }) as typeof container.appendChild; + + try { + return await create(); + } finally { + container.appendChild = originalAppend; + } +} + +/** + * Continue Postmate handshake `postMessage`s after stock Postmate stops at 5 tries. + * Parent's reply listener stays registered — extra handshakes still complete the Promise. + */ +async function withExtendedPostmateHandshake( + container: HTMLElement, + walletUrl: string, + create: () => Promise, +): Promise { + let childOrigin: string; + try { + childOrigin = new URL(walletUrl).origin; + } catch { + return create(); + } + + let intervalId: ReturnType | undefined; + let stopTimerId: ReturnType | undefined; + + const stop = () => { + if (intervalId !== undefined) { + clearInterval(intervalId); + intervalId = undefined; + } + if (stopTimerId !== undefined) { + clearTimeout(stopTimerId); + stopTimerId = undefined; + } + }; + + const pulse = () => { + const iframe = container.querySelector("iframe"); + const child = iframe?.contentWindow; + if (!child) return; + child.postMessage( + { + postmate: "handshake", + type: POSTMATE_MESSAGE_TYPE, + model: {}, + }, + childOrigin, + ); + }; + + // Start extending after Postmate's native window (~2.5s) so we don't duplicate + // the early attempts heavily; then pulse until connected or budget expires. + const startExtending = () => { + stopTimerId = setTimeout(stop, EXTENDED_HANDSHAKE_BUDGET_MS); + intervalId = setInterval(pulse, EXTENDED_HANDSHAKE_INTERVAL_MS); + }; + const extendDelayId = setTimeout(startExtending, 2_000); + + try { + const parent = await create(); + clearTimeout(extendDelayId); + stop(); + return parent; + } catch (error) { + clearTimeout(extendDelayId); + stop(); + throw error; + } +} diff --git a/packages/ows-provider/src/analytics/AnalyticsHostHandler.ts b/packages/ows-provider/src/analytics/AnalyticsHostHandler.ts new file mode 100644 index 0000000..d34885c --- /dev/null +++ b/packages/ows-provider/src/analytics/AnalyticsHostHandler.ts @@ -0,0 +1,110 @@ +import { + OWS_ANALYTICS_EVENT, + deserializeAnalyticsEvent, + type IOWSAnalyticsEvent, +} from "@1shotapi/ows-types"; +import type Postmate from "postmate"; + +export type AnalyticsListener = (event: IOWSAnalyticsEvent) => void; + +/** + * Host-side fan-out for Branding→Host `ows:analytics` events. + * Delivers the full rich payload (OWS base fields typed; extras untyped). + */ +export class AnalyticsHostHandler { + private readonly allListeners = new Set(); + private readonly namedListeners = new Map>(); + private destroyed = false; + + constructor(parent: Postmate.ParentAPI) { + parent.on(OWS_ANALYTICS_EVENT, (data: unknown) => { + this.handleAnalytics(data); + }); + } + + /** + * Subscribe to all analytics events, or only those matching `name`. + * Returns an unsubscribe function. + */ + on(listener: AnalyticsListener): () => void; + on(name: string, listener: AnalyticsListener): () => void; + on( + nameOrListener: string | AnalyticsListener, + maybeListener?: AnalyticsListener, + ): () => void { + if (typeof nameOrListener === "function") { + const listener = nameOrListener; + this.allListeners.add(listener); + return () => { + this.allListeners.delete(listener); + }; + } + + const name = nameOrListener; + const listener = maybeListener; + if (!listener) { + throw new Error("analytics.on(name, listener) requires a listener"); + } + let set = this.namedListeners.get(name); + if (!set) { + set = new Set(); + this.namedListeners.set(name, set); + } + set.add(listener); + return () => { + set!.delete(listener); + if (set!.size === 0) { + this.namedListeners.delete(name); + } + }; + } + + off(listener: AnalyticsListener): void { + this.allListeners.delete(listener); + for (const [name, set] of this.namedListeners) { + set.delete(listener); + if (set.size === 0) { + this.namedListeners.delete(name); + } + } + } + + destroy(): void { + this.destroyed = true; + this.allListeners.clear(); + this.namedListeners.clear(); + } + + private handleAnalytics(data: unknown): void { + if (this.destroyed) { + return; + } + + let event: IOWSAnalyticsEvent; + try { + event = deserializeAnalyticsEvent(data); + } catch { + return; + } + + for (const listener of this.allListeners) { + try { + listener(event); + } catch { + // Host listener errors must not break the proxy. + } + } + + const named = this.namedListeners.get(event.name); + if (!named) { + return; + } + for (const listener of named) { + try { + listener(event); + } catch { + // Host listener errors must not break the proxy. + } + } + } +} diff --git a/packages/ows-provider/src/bitcoin/BitcoinHostClient.ts b/packages/ows-provider/src/bitcoin/BitcoinHostClient.ts new file mode 100644 index 0000000..4693067 --- /dev/null +++ b/packages/ows-provider/src/bitcoin/BitcoinHostClient.ts @@ -0,0 +1,23 @@ +import { + BITCOIN_WIRE_METHODS, + type IBitcoinAccountAddressEntry, + type IBitcoinGetAccountAddressesParams, + type IOpenWalletBitcoinProvider, +} from "@1shotapi/ows-types"; +import type { RpcHostClient } from "../rpc/RpcHostClient.js"; + +/** + * Host-side Bitcoin namespace — conforms to the Reown / BIP-122 static-wallet pattern. + */ +export class BitcoinHostClient implements IOpenWalletBitcoinProvider { + constructor(private readonly rpcClient: RpcHostClient) {} + + getAccountAddresses( + params?: IBitcoinGetAccountAddressesParams, + ): Promise { + return this.rpcClient.request( + BITCOIN_WIRE_METHODS.getAccountAddresses, + params ?? {}, + ); + } +} diff --git a/packages/ows-provider/src/credentials/CredentialsHostClient.ts b/packages/ows-provider/src/credentials/CredentialsHostClient.ts new file mode 100644 index 0000000..8dbb623 --- /dev/null +++ b/packages/ows-provider/src/credentials/CredentialsHostClient.ts @@ -0,0 +1,45 @@ +import { + CREDENTIAL_WIRE_METHODS, + type OpenWalletCredentialProvider, + type CredentialOfferInput, + type CredentialReceipt, + type PresentationRequestInput, + type PresentationResult, + type CredentialFilter, + type CredentialSummary, +} from "@1shotapi/ows-types"; +import type { CredentialId } from "@1shotapi/ows-types"; +import type { RpcHostClient } from "../rpc/RpcHostClient.js"; + +/** Host-side credentials namespace — routes via namespaced wire keys only. */ +export class CredentialHostClient implements OpenWalletCredentialProvider { + constructor(private readonly rpcClient: RpcHostClient) {} + + acceptOffer(input: CredentialOfferInput): Promise { + return this.rpcClient.request( + CREDENTIAL_WIRE_METHODS.acceptOffer, + input, + ); + } + + present(input: PresentationRequestInput): Promise { + return this.rpcClient.request( + CREDENTIAL_WIRE_METHODS.present, + input, + ); + } + + list(filter?: CredentialFilter): Promise { + return this.rpcClient.request( + CREDENTIAL_WIRE_METHODS.list, + filter ?? null, + ); + } + + delete(input: { credentialId: CredentialId }): Promise { + return this.rpcClient.request( + CREDENTIAL_WIRE_METHODS.delete, + input, + ); + } +} diff --git a/packages/ows-provider/src/credentials/verify.ts b/packages/ows-provider/src/credentials/verify.ts new file mode 100644 index 0000000..8d2d5d6 --- /dev/null +++ b/packages/ows-provider/src/credentials/verify.ts @@ -0,0 +1,78 @@ +import { SDJwtVcInstance, type SdJwtVcPayload } from "@sd-jwt/sd-jwt-vc"; +import type { VerifierOptions } from "@sd-jwt/core"; +import { + CredentialCryptoUtils, + PresentationUtils, + type SdJwtVcPresentationString, +} from "@1shotapi/ows-types"; + +export type VerifySdJwtVcPresentationInput = { + presentation: SdJwtVcPresentationString; + issuerPublicKeyJwk: JsonWebKey; + holderPublicKeyJwk: JsonWebKey; + nonce: string; + audience: string; + options?: VerifierOptions; +}; + +export type VerifySdJwtVcPresentationResult = { + valid: boolean; + reasons: string[]; + payload?: SdJwtVcPayload; +}; + +/** Cryptographically verifies an SD-JWT VC presentation (issuer sig + kb+jwt). */ +export async function verifySdJwtVcPresentation( + input: VerifySdJwtVcPresentationInput, +): Promise { + const issuerVerifier = CredentialCryptoUtils.createEd25519Verifier( + input.issuerPublicKeyJwk, + ); + const holderVerifier = CredentialCryptoUtils.createEd25519Verifier( + input.holderPublicKeyJwk, + ); + + const sdjwt = new SDJwtVcInstance({ + verifier: issuerVerifier, + kbVerifier: holderVerifier, + hasher: CredentialCryptoUtils.hasher, + hashAlg: "sha-256", + }); + + const result = await sdjwt.safeVerify(input.presentation, { + keyBindingNonce: input.nonce, + ...input.options, + }); + + const reasons: string[] = []; + if (!result.success) { + reasons.push(...result.errors.map((e) => e.message)); + } + + // @sd-jwt only checks keyBindingNonce — enforce audience from kb+jwt claims. + const kb = PresentationUtils.extractKbJwtClaims(input.presentation); + if (!kb) { + reasons.push("Missing kb+jwt in presentation"); + } else { + if (kb.aud !== input.audience) { + reasons.push( + `kb+jwt audience mismatch: expected ${input.audience}, got ${kb.aud ?? "(missing)"}`, + ); + } + if (kb.nonce !== input.nonce) { + reasons.push( + `kb+jwt nonce mismatch: expected ${input.nonce}, got ${kb.nonce ?? "(missing)"}`, + ); + } + } + + if (reasons.length > 0) { + return { valid: false, reasons }; + } + + return { + valid: true, + reasons: [], + payload: result.success ? result.data.payload : undefined, + }; +} diff --git a/packages/ows-provider/src/display/DisplayHostHandler.ts b/packages/ows-provider/src/display/DisplayHostHandler.ts new file mode 100644 index 0000000..3fc3ce6 --- /dev/null +++ b/packages/ows-provider/src/display/DisplayHostHandler.ts @@ -0,0 +1,805 @@ +import type Postmate from "postmate"; +import { + OWS_RELEASE_DISPLAY_EVENT, + OWS_REQUEST_DISPLAY_EVENT, + OWS_REQUEST_HIDE_EVENT, + OWS_DISPLAY_READY_MODEL_METHOD, + OWS_HIDE_READY_MODEL_METHOD, + deserializeReleaseDisplay, + deserializeRequestDisplay, + deserializeRequestHide, + serializeRpc, + type DisplayRequestId, + type RequestDisplayParams, +} from "@1shotapi/ows-types"; + +const POPOVER_MARGIN_PX = 16; +const POPOVER_SHADOW = + "0 8px 32px color-mix(in srgb, CanvasText 22%, transparent)"; +/** Wipe duration for drawer open/close (ms). */ +const DRAWER_ANIMATION_MS = 280; +const DRAWER_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; + +/** + * How the host presents the Branding Layer iframe. + * Fixed at {@link OWSProxy.create} — do not move/reparent the iframe later + * (Postmate messaging breaks if the frame is detached/reparented). + * + * To change presentation, destroy the proxy and create a new one against the + * desired container with a new mode. + */ +export enum EWalletPresentationMode { + /** + * Host-controlled panel: flyout (lower-right) when the viewport is wide + * enough for `walletSizeX + 32px`; full-screen drawer with bottom wipe when + * not. Responds to show/hide and branding display requests. + */ + Flyout = "flyout", + /** + * Always-visible fill of the create() container (sidebar, design slot, etc.). + * Show/hide and branding hide do not collapse the panel. + */ + Inline = "inline", +} + +/** Default visible wallet size (host-controlled; MetaMask-like). */ +export const DEFAULT_WALLET_SIZE_X = 360; +export const DEFAULT_WALLET_SIZE_Y = 600; + +export type DisplayHostHandlerOptions = { + /** Visible panel width in CSS pixels. Default: {@link DEFAULT_WALLET_SIZE_X}. */ + walletSizeX?: number; + /** Visible panel height in CSS pixels. Default: {@link DEFAULT_WALLET_SIZE_Y}. */ + walletSizeY?: number; + /** + * Presentation mode for this proxy instance. Default: flyout. + * Immutable after construct — recreate the proxy to switch modes. + */ + presentationMode?: EWalletPresentationMode; +}; + +type StoredLayout = { + frameClassName: string; + frameStyle: Record; + containerClassName: string; + containerStyle: Record; +}; + +type EVisibleLayoutKind = "flyout" | "drawer"; + +function captureInlineStyles(element: HTMLElement): Record { + const styles: Record = {}; + for (let i = 0; i < element.style.length; i++) { + const name = element.style.item(i); + styles[name] = element.style.getPropertyValue(name); + } + return styles; +} + +function restoreInlineStyles( + element: HTMLElement, + className: string, + styles: Record, +): void { + element.className = className; + element.removeAttribute("style"); + for (const [name, value] of Object.entries(styles)) { + element.style.setProperty(name, value); + } +} + +export class DisplayHostHandler { + private originalLayout: StoredLayout | null = null; + private childDisplayId: DisplayRequestId | null = null; + private rpcAccessCount = 0; + /** Host-initiated visible panel (flyout mode). */ + private hostDisplayActive = false; + /** True while the last applied layout was 1×1 WebAuthn passthrough. */ + private usePassthroughLayout = false; + /** Last non-passthrough visible layout (for close animation). */ + private visibleLayoutKind: EVisibleLayoutKind | null = null; + private hideAnimation: Animation | null = null; + private readonly walletSizeX: number; + private readonly walletSizeY: number; + private readonly presentationMode: EWalletPresentationMode; + private readonly onViewportChange: () => void; + + constructor( + private readonly parent: Postmate.ParentAPI, + options?: DisplayHostHandlerOptions, + ) { + this.walletSizeX = options?.walletSizeX ?? DEFAULT_WALLET_SIZE_X; + this.walletSizeY = options?.walletSizeY ?? DEFAULT_WALLET_SIZE_Y; + this.presentationMode = + options?.presentationMode ?? EWalletPresentationMode.Flyout; + + this.onViewportChange = () => { + if (this.isInline || this.usePassthroughLayout) { + return; + } + if (!this.hostDisplayActive && this.childDisplayId === null) { + return; + } + this.showVisiblePanel({ animate: false }); + }; + + if (typeof window !== "undefined") { + window.addEventListener("resize", this.onViewportChange); + } + + parent.on(OWS_REQUEST_DISPLAY_EVENT, (data: unknown) => { + this.handleRequestDisplay(data); + }); + parent.on(OWS_RELEASE_DISPLAY_EVENT, (data: unknown) => { + this.handleReleaseDisplay(data); + }); + parent.on(OWS_REQUEST_HIDE_EVENT, (data: unknown) => { + void this.handleRequestHide(data); + }); + } + + get isInline(): boolean { + return this.presentationMode === EWalletPresentationMode.Inline; + } + + /** + * True when the host viewport is too narrow for the configured wallet width + * plus a 16px margin on each side — use a full-screen drawer instead of a flyout. + * + * Width-only: a short-but-wide desktop window (common on 1080p @ 150% DPI with + * browser chrome) should keep the corner flyout, not jump to mobile drawer UX. + */ + shouldUseDrawer(): boolean { + if (typeof window === "undefined") { + return false; + } + const margin = POPOVER_MARGIN_PX * 2; + const width = window.visualViewport?.width ?? window.innerWidth; + return width < this.walletSizeX + margin; + } + + destroy(): void { + if (typeof window !== "undefined") { + window.removeEventListener("resize", this.onViewportChange); + } + this.hideAnimation?.cancel(); + this.hideAnimation = null; + this.hostDisplayActive = false; + if (!this.isInline) { + this.hideLayoutSync(); + } + } + + /** + * Collapse the host container until {@link show} (flyout only). + * Inline mode skips this — the create() container stays filled/visible. + */ + initializeHidden(): void { + if (this.isInline) { + return; + } + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + return; + } + + this.applyHiddenLayout(frame); + this.captureOriginalLayout(frame); + } + + /** + * Inline mode: fill the create() container and keep the panel visible. + * Call after Postmate handshake when {@link EWalletPresentationMode.Inline}. + */ + initializeInlineVisible(): void { + if (!this.isInline) { + return; + } + this.hostDisplayActive = true; + this.showVisiblePanel({ animate: false }); + } + + /** + * Host-initiated show. Flyout or drawer depending on viewport; inline fills. + */ + show(): void { + this.hostDisplayActive = true; + this.showVisiblePanel({ animate: true }); + this.focusFrame(); + } + + /** + * Host-initiated hide. Flyout/drawer: collapse unless branding holds a session. + * Inline: no-op (panel stays in the page slot). + */ + hide(): void { + if (this.isInline) { + return; + } + this.hostDisplayActive = false; + if (this.rpcAccessCount === 0 && !this.childDisplayId) { + void this.hideLayoutAnimated(); + } + } + + /** + * Synchronously show/focus the wallet iframe before Postmate RPC is sent. + * Preserves host user activation for WebAuthn in cross-origin embeds. + * + * Never reparents the iframe (that breaks Postmate). When a full-size panel + * is already visible, keep it instead of collapsing to 1×1. + */ + prepareForRpcAccess(): void { + this.rpcAccessCount++; + if (this.isInline || this.hostDisplayActive) { + this.focusFrame(); + return; + } + if (this.childDisplayId !== null && !this.usePassthroughLayout) { + this.focusFrame(); + return; + } + this.showPassthroughLayout(); + this.focusFrame(); + } + + /** Restore/hide after RPC unless branding still holds a display session. */ + completeRpcAccess(): void { + this.rpcAccessCount = Math.max(0, this.rpcAccessCount - 1); + if (this.rpcAccessCount !== 0) { + return; + } + + const restoreOrHide = (): void => { + if (this.rpcAccessCount !== 0) { + return; + } + if (this.isInline) { + this.showVisiblePanel({ animate: false }); + return; + } + if (this.childDisplayId || this.hostDisplayActive) { + this.showVisiblePanel({ animate: false }); + return; + } + void this.hideLayoutAnimated(); + }; + + if (typeof globalThis.setTimeout === "function") { + globalThis.setTimeout(restoreOrHide, 250); + } else { + restoreOrHide(); + } + } + + private handleRequestDisplay(data: unknown): void { + let envelope; + try { + envelope = deserializeRequestDisplay(data); + } catch { + return; + } + + this.childDisplayId = envelope.displayId; + + if (this.isInline) { + this.showVisiblePanel({ animate: false }); + this.focusFrame(); + this.notifyDisplayReady(envelope.displayId); + return; + } + + this.showVisiblePanel({ animate: true }); + this.focusFrame(); + this.notifyDisplayReady(envelope.displayId); + } + + private showVisiblePanel(options?: { animate?: boolean }): void { + const animate = options?.animate !== false; + if (this.isInline) { + this.showInlineLayout(); + return; + } + if (this.shouldUseDrawer()) { + this.showDrawerLayout({ animate }); + } else { + this.showFlyoutLayout(); + } + } + + private handleReleaseDisplay(data: unknown): void { + let envelope; + try { + envelope = deserializeReleaseDisplay(data); + } catch { + return; + } + + if (this.childDisplayId !== envelope.displayId) { + return; + } + + this.childDisplayId = null; + if (this.isInline) { + this.showVisiblePanel({ animate: false }); + return; + } + if (this.rpcAccessCount === 0 && !this.hostDisplayActive) { + void this.hideLayoutAnimated(); + } + } + + private async handleRequestHide(data: unknown): Promise { + let envelope; + try { + envelope = deserializeRequestHide(data); + } catch { + return; + } + + if ( + envelope.displayId && + this.childDisplayId && + this.childDisplayId !== envelope.displayId + ) { + // Stale hide from a superseded display session — still ack so branding + // does not hang forever waiting for hideReady (blocks Connect UX). + this.notifyHideReady(envelope.displayId); + return; + } + + this.childDisplayId = null; + + if (this.isInline) { + this.notifyHideReady(envelope.displayId); + this.showVisiblePanel({ animate: false }); + return; + } + + this.hostDisplayActive = false; + await this.hideLayoutAnimated(); + this.notifyHideReady(envelope.displayId); + } + + private notifyDisplayReady(displayId: DisplayRequestId): void { + this.parent.call( + OWS_DISPLAY_READY_MODEL_METHOD, + serializeRpc({ displayId } satisfies { displayId: DisplayRequestId }), + ); + } + + private notifyHideReady(displayId?: DisplayRequestId): void { + this.parent.call( + OWS_HIDE_READY_MODEL_METHOD, + serializeRpc(displayId ? { displayId } : {}), + ); + } + + private captureOriginalLayout(frame: HTMLIFrameElement): void { + if (this.originalLayout) { + return; + } + + const container = frame.parentElement; + this.originalLayout = { + frameClassName: frame.className, + frameStyle: captureInlineStyles(frame), + containerClassName: container?.className ?? "", + containerStyle: container ? captureInlineStyles(container) : {}, + }; + } + + private prepareFrameFill(frame: HTMLIFrameElement): HTMLElement | null { + const container = frame.parentElement; + this.captureOriginalLayout(frame); + this.usePassthroughLayout = false; + this.hideAnimation?.cancel(); + this.hideAnimation = null; + + if (container) { + container.style.setProperty("display", "block", "important"); + container.style.setProperty("clip-path", "none", "important"); + container.style.setProperty("overflow", "hidden", "important"); + revealWalletContainer(container); + } + + frame.style.setProperty("display", "block", "important"); + frame.style.setProperty("position", "static", "important"); + frame.style.setProperty("width", "100%", "important"); + frame.style.setProperty("height", "100%", "important"); + frame.style.setProperty("border", "none", "important"); + frame.style.setProperty("margin", "0", "important"); + frame.style.setProperty("padding", "0", "important"); + frame.style.setProperty("transform", "none", "important"); + frame.style.removeProperty("top"); + frame.style.removeProperty("left"); + frame.style.removeProperty("right"); + frame.style.removeProperty("bottom"); + frame.style.removeProperty("z-index"); + frame.removeAttribute("aria-hidden"); + frame.style.setProperty("opacity", "1", "important"); + frame.style.setProperty("pointer-events", "auto", "important"); + frame.style.setProperty("background", "Canvas", "important"); + frame.style.setProperty("color-scheme", "light dark", "important"); + frame.style.removeProperty("border-radius"); + frame.style.removeProperty("box-shadow"); + + return container; + } + + private showPassthroughLayout(): void { + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + return; + } + const container = this.prepareFrameFill(frame); + this.usePassthroughLayout = true; + this.visibleLayoutKind = null; + + if (container) { + container.style.setProperty("position", "fixed", "important"); + container.style.setProperty("inset", "auto", "important"); + container.style.setProperty("top", "0", "important"); + container.style.setProperty("left", "0", "important"); + container.style.setProperty("bottom", "auto", "important"); + container.style.setProperty("right", "auto", "important"); + container.style.setProperty("width", "1px", "important"); + container.style.setProperty("height", "1px", "important"); + container.style.setProperty("pointer-events", "auto", "important"); + container.style.setProperty("z-index", "9999", "important"); + container.style.setProperty("opacity", "0", "important"); + container.style.setProperty("background", "transparent", "important"); + container.style.setProperty("transform", "none", "important"); + container.style.removeProperty("border-radius"); + container.style.removeProperty("box-shadow"); + container.style.removeProperty("transition"); + } + + frame.style.setProperty("opacity", "0", "important"); + frame.style.setProperty("pointer-events", "auto", "important"); + } + + private showFlyoutLayout(): void { + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + return; + } + const container = this.prepareFrameFill(frame); + if (!container) return; + + this.visibleLayoutKind = "flyout"; + container.style.setProperty("position", "fixed", "important"); + container.style.setProperty("inset", "auto", "important"); + container.style.setProperty("top", "auto", "important"); + container.style.setProperty("left", "auto", "important"); + container.style.setProperty( + "bottom", + `${POPOVER_MARGIN_PX}px`, + "important", + ); + container.style.setProperty( + "right", + `${POPOVER_MARGIN_PX}px`, + "important", + ); + container.style.setProperty("width", `${this.walletSizeX}px`, "important"); + container.style.setProperty("height", `${this.walletSizeY}px`, "important"); + container.style.setProperty("pointer-events", "auto", "important"); + container.style.setProperty("z-index", "9999", "important"); + container.style.setProperty("opacity", "1", "important"); + container.style.setProperty("background", "Canvas", "important"); + container.style.setProperty("border-radius", "12px", "important"); + container.style.setProperty("box-shadow", POPOVER_SHADOW, "important"); + container.style.setProperty("transform", "none", "important"); + container.style.removeProperty("transition"); + } + + private showDrawerLayout(options?: { animate?: boolean }): void { + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + return; + } + const container = this.prepareFrameFill(frame); + if (!container) return; + + const animate = options?.animate !== false; + this.visibleLayoutKind = "drawer"; + + container.style.setProperty("position", "fixed", "important"); + container.style.setProperty("inset", "0", "important"); + container.style.setProperty("top", "0", "important"); + container.style.setProperty("left", "0", "important"); + container.style.setProperty("right", "0", "important"); + container.style.setProperty("bottom", "0", "important"); + container.style.setProperty("width", "100%", "important"); + container.style.setProperty("height", "100%", "important"); + container.style.setProperty("margin", "0", "important"); + container.style.setProperty("pointer-events", "auto", "important"); + container.style.setProperty("z-index", "9999", "important"); + container.style.setProperty("opacity", "1", "important"); + container.style.setProperty("background", "Canvas", "important"); + container.style.setProperty("border-radius", "0", "important"); + container.style.removeProperty("box-shadow"); + container.style.removeProperty("transition"); + + if (animate && typeof container.animate === "function") { + container.style.removeProperty("transform"); + const animation = container.animate( + [ + { transform: "translateY(100%)" }, + { transform: "translateY(0)" }, + ], + { + duration: DRAWER_ANIMATION_MS, + easing: DRAWER_EASING, + fill: "forwards", + }, + ); + void animation.finished + .then(() => { + container.style.setProperty("transform", "none", "important"); + animation.cancel(); + }) + .catch(() => { + container.style.setProperty("transform", "none", "important"); + }); + } else { + container.style.setProperty("transform", "none", "important"); + } + } + + private showInlineLayout(): void { + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + return; + } + const container = this.prepareFrameFill(frame); + if (!container) return; + + this.visibleLayoutKind = null; + container.style.setProperty("position", "absolute", "important"); + container.style.setProperty("inset", "0", "important"); + container.style.setProperty("top", "0", "important"); + container.style.setProperty("left", "0", "important"); + container.style.setProperty("right", "0", "important"); + container.style.setProperty("bottom", "0", "important"); + container.style.setProperty("width", "100%", "important"); + container.style.setProperty("height", "100%", "important"); + container.style.setProperty("margin", "0", "important"); + container.style.setProperty("pointer-events", "auto", "important"); + container.style.setProperty("z-index", "1", "important"); + container.style.setProperty("opacity", "1", "important"); + container.style.setProperty("background", "Canvas", "important"); + container.style.setProperty("border-radius", "12px", "important"); + container.style.setProperty("transform", "none", "important"); + container.style.removeProperty("box-shadow"); + container.style.removeProperty("transition"); + container.style.setProperty( + "--ows-wallet-size-x", + `${this.walletSizeX}px`, + ); + container.style.setProperty( + "--ows-wallet-size-y", + `${this.walletSizeY}px`, + ); + } + + private applyHiddenLayout(frame: HTMLIFrameElement): void { + releaseWalletFrameFocus(frame); + const container = frame.parentElement; + if (container) { + applyHiddenWalletContainerStyles(container); + } + applyHiddenWalletFrameStyles(frame); + this.usePassthroughLayout = false; + this.visibleLayoutKind = null; + } + + private focusFrame(): void { + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + return; + } + + try { + frame.contentWindow?.focus(); + frame.focus(); + } catch (error) { + console.warn("[ows-provider] Could not focus wallet iframe:", error); + } + } + + private async hideLayoutAnimated(): Promise { + const wasDrawer = this.visibleLayoutKind === "drawer"; + const frame = this.parent.frame; + const container = + frame instanceof HTMLIFrameElement ? frame.parentElement : null; + + // Release focus before any hide styling / aria-hidden so Chrome does not + // warn about a focused iframe under an aria-hidden ancestor (common after + // WebAuthn leaves focus inside the branding/signing frame). + if (frame instanceof HTMLIFrameElement) { + releaseWalletFrameFocus(frame); + } + + if ( + wasDrawer && + container && + typeof container.animate === "function" && + !this.usePassthroughLayout + ) { + this.hideAnimation?.cancel(); + container.style.removeProperty("transform"); + try { + const animation = container.animate( + [ + { transform: "translateY(0)" }, + { transform: "translateY(100%)" }, + ], + { + duration: DRAWER_ANIMATION_MS, + easing: DRAWER_EASING, + fill: "forwards", + }, + ); + this.hideAnimation = animation; + await animation.finished; + } catch { + // Interrupted / unsupported — fall through to sync hide. + } + this.hideAnimation = null; + } + + this.hideLayoutSync(); + } + + private hideLayoutSync(): void { + this.usePassthroughLayout = false; + this.visibleLayoutKind = null; + + if (!this.originalLayout) { + const frame = this.parent.frame; + if (frame instanceof HTMLIFrameElement) { + this.applyHiddenLayout(frame); + } + return; + } + + const frame = this.parent.frame; + if (!(frame instanceof HTMLIFrameElement)) { + this.originalLayout = null; + return; + } + + const container = frame.parentElement; + const stored = this.originalLayout; + + releaseWalletFrameFocus(frame); + + restoreInlineStyles(frame, stored.frameClassName, stored.frameStyle); + if (container) { + restoreInlineStyles( + container, + stored.containerClassName, + stored.containerStyle, + ); + // Always inert + aria-hidden when collapsed — do not restore a host's + // pre-create markup flags if that would leave a focused iframe exposed. + concealWalletContainer(container, { ariaHidden: "true", inert: true }); + } + + this.originalLayout = null; + } +} + +/** + * Move focus out of the branding iframe before collapsing it. + * `document.body.focus()` is a no-op (body is not focusable by default), so + * after WebAuthn the iframe often remains `document.activeElement` — setting + * `aria-hidden` on its ancestor then trips Chrome's accessibility warning. + */ +export function releaseWalletFrameFocus(frame: HTMLIFrameElement): void { + try { + frame.blur(); + } catch { + // ignore + } + + if (typeof document === "undefined") { + return; + } + + const active = document.activeElement; + if (active !== frame && !(active instanceof Node && frame.contains(active))) { + return; + } + + // Temporary focus sink: remove after focus so activeElement becomes . + const sink = document.createElement("span"); + sink.tabIndex = -1; + sink.setAttribute("aria-hidden", "true"); + sink.style.cssText = + "position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;overflow:hidden;left:0;top:0;"; + document.body.appendChild(sink); + try { + sink.focus({ preventScroll: true }); + } finally { + sink.remove(); + } +} + +function concealWalletContainer( + container: HTMLElement, + options: { ariaHidden: string | null; inert: boolean }, +): void { + // inert first — browsers move focus out of the subtree, avoiding the + // "aria-hidden on focused descendant" warning. + container.inert = options.inert; + if (options.ariaHidden !== null) { + container.setAttribute("aria-hidden", options.ariaHidden); + } else if (options.inert) { + container.setAttribute("aria-hidden", "true"); + } else { + container.removeAttribute("aria-hidden"); + } +} + +function revealWalletContainer(container: HTMLElement): void { + container.inert = false; + container.removeAttribute("aria-hidden"); +} + +/** + * Collapse the host wallet container before the iframe exists. + * Call before Postmate appends the iframe so hosts need no wallet CSS. + */ +export function applyHiddenWalletContainerStyles(container: HTMLElement): void { + container.style.setProperty("display", "block", "important"); + container.style.setProperty("position", "fixed", "important"); + container.style.setProperty("inset", "auto", "important"); + container.style.setProperty("top", "0", "important"); + container.style.setProperty("left", "0", "important"); + container.style.setProperty("bottom", "auto", "important"); + container.style.setProperty("right", "auto", "important"); + container.style.setProperty("width", "0", "important"); + container.style.setProperty("height", "0", "important"); + container.style.setProperty("overflow", "hidden", "important"); + container.style.setProperty("clip-path", "inset(50%)", "important"); + container.style.setProperty("pointer-events", "none", "important"); + container.style.setProperty("opacity", "0", "important"); + container.style.setProperty("z-index", "-1", "important"); + container.style.setProperty("transform", "none", "important"); + container.style.removeProperty("border-radius"); + container.style.removeProperty("box-shadow"); + container.style.removeProperty("background"); + container.style.removeProperty("transition"); + concealWalletContainer(container, { ariaHidden: "true", inert: true }); +} + +/** + * Hide the branding iframe as soon as Postmate appends it (before handshake). + */ +export function applyHiddenWalletFrameStyles(frame: HTMLIFrameElement): void { + frame.style.setProperty("display", "block", "important"); + frame.style.setProperty("position", "static", "important"); + frame.style.setProperty("width", "100%", "important"); + frame.style.setProperty("height", "100%", "important"); + frame.style.setProperty("border", "none", "important"); + frame.style.setProperty("margin", "0", "important"); + frame.style.setProperty("padding", "0", "important"); + frame.style.setProperty("transform", "none", "important"); + frame.style.setProperty("pointer-events", "none", "important"); + frame.style.setProperty("opacity", "0", "important"); + frame.style.removeProperty("top"); + frame.style.removeProperty("left"); + frame.style.removeProperty("right"); + frame.style.removeProperty("bottom"); + frame.style.removeProperty("z-index"); + frame.style.removeProperty("border-radius"); + frame.style.removeProperty("box-shadow"); + frame.style.removeProperty("background"); + frame.style.removeProperty("color-scheme"); + frame.removeAttribute("aria-hidden"); +} + +export type { RequestDisplayParams }; diff --git a/packages/ows-provider/src/eip1193/EIP1193Provider.ts b/packages/ows-provider/src/eip1193/EIP1193Provider.ts new file mode 100644 index 0000000..7a8835b --- /dev/null +++ b/packages/ows-provider/src/eip1193/EIP1193Provider.ts @@ -0,0 +1,73 @@ +import type { + EIP1193RequestArgs, + EIP1193RequestArgsFor, + EIP1193Requests, + KnownEIP1193Method, +} from "@1shotapi/ows-types"; + +type RpcInvoker = (method: string, params: unknown) => Promise; + +export class EIP1193Provider { + private readonly listeners = new Map< + string, + Set<(...args: unknown[]) => void> + >(); + + constructor(private readonly invoke: RpcInvoker) {} + + request( + args: M extends KnownEIP1193Method + ? EIP1193RequestArgsFor + : EIP1193RequestArgs, + ): Promise< + M extends KnownEIP1193Method ? EIP1193Requests[M]["result"] : unknown + > { + const rpcParams = normalizeEip1193Params(args.params); + return this.invoke(args.method, rpcParams) as Promise< + M extends KnownEIP1193Method ? EIP1193Requests[M]["result"] : unknown + >; + } + + on(event: string, listener: (...args: unknown[]) => void): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(listener); + } + + removeListener(event: string, listener: (...args: unknown[]) => void): void { + this.listeners.get(event)?.delete(listener); + } + + /** + * Notify `on` listeners. Used when Branding pushes `ows:eip1193` + * (`chainChanged`, `accountsChanged`, …). + */ + emit(event: string, ...params: unknown[]): void { + const set = this.listeners.get(event); + if (!set || set.size === 0) { + return; + } + for (const listener of set) { + try { + listener(...params); + } catch { + // Host listener errors must not break the provider. + } + } + } +} + +function normalizeEip1193Params( + params: EIP1193RequestArgs["params"], +): unknown[] { + if (params === undefined) { + return []; + } + if (Array.isArray(params)) { + return [...params]; + } + return [params]; +} diff --git a/packages/ows-provider/src/eip1193/Eip1193EventHostHandler.ts b/packages/ows-provider/src/eip1193/Eip1193EventHostHandler.ts new file mode 100644 index 0000000..b374dc8 --- /dev/null +++ b/packages/ows-provider/src/eip1193/Eip1193EventHostHandler.ts @@ -0,0 +1,42 @@ +import { + OWS_EIP1193_EVENT, + deserializeEip1193EventNotification, +} from "@1shotapi/ows-types"; +import type Postmate from "postmate"; +import type { EIP1193Provider } from "./EIP1193Provider.js"; + +/** + * Host-side delivery for Branding→Host `ows:eip1193` notifications. + * Forwards into {@link EIP1193Provider} listeners (`chainChanged`, …). + */ +export class Eip1193EventHostHandler { + private destroyed = false; + + constructor( + parent: Postmate.ParentAPI, + private readonly provider: EIP1193Provider, + ) { + parent.on(OWS_EIP1193_EVENT, (data: unknown) => { + this.handleNotification(data); + }); + } + + destroy(): void { + this.destroyed = true; + } + + private handleNotification(data: unknown): void { + if (this.destroyed) { + return; + } + + let notification; + try { + notification = deserializeEip1193EventNotification(data); + } catch { + return; + } + + this.provider.emit(notification.event, ...notification.params); + } +} diff --git a/packages/ows-provider/src/index.ts b/packages/ows-provider/src/index.ts new file mode 100644 index 0000000..756ac2d --- /dev/null +++ b/packages/ows-provider/src/index.ts @@ -0,0 +1,32 @@ +export { OWSProxy } from "./OWSProxy.js"; +export type { OWSProxyOptions } from "./OWSProxy.js"; +export { + DEFAULT_WALLET_SIZE_X, + DEFAULT_WALLET_SIZE_Y, + EWalletPresentationMode, +} from "./display/DisplayHostHandler.js"; +export { + EIP1193Provider, +} from "./eip1193/EIP1193Provider.js"; +export { Eip1193EventHostHandler } from "./eip1193/Eip1193EventHostHandler.js"; + +export { CredentialHostClient } from "./credentials/CredentialsHostClient.js"; +export { BitcoinHostClient } from "./bitcoin/BitcoinHostClient.js"; +export { + SolanaHostClient, + type RegisterSolanaWalletOptions, +} from "./solana/SolanaHostClient.js"; +export { + OwsSolanaStandardWallet, + SOLANA_STANDARD_CHAINS, + type OwsSolanaStandardWalletOptions, +} from "./solana/standard-wallet.js"; +export { + AnalyticsHostHandler, + type AnalyticsListener, +} from "./analytics/AnalyticsHostHandler.js"; +export { + verifySdJwtVcPresentation, + type VerifySdJwtVcPresentationInput, + type VerifySdJwtVcPresentationResult, +} from "./credentials/verify.js"; diff --git a/packages/ows-provider/src/rpc/RpcHostClient.ts b/packages/ows-provider/src/rpc/RpcHostClient.ts new file mode 100644 index 0000000..dd9d1d4 --- /dev/null +++ b/packages/ows-provider/src/rpc/RpcHostClient.ts @@ -0,0 +1,109 @@ +import Postmate from "postmate"; +import { + deserializeRpcResponse, + OwsRpcError, + OWS_RPC_CALLBACK_EVENT, + serializeRpc, + type RpcRequestEnvelope, + type RpcResponseEnvelope, + RPCCallId, +} from "@1shotapi/ows-types"; + +type PendingRpc = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +export type RpcHostLifecycle = { + beforeRequest?: () => void; + afterRequest?: () => void; +}; + +/** + * Host → Branding RPC over Postmate. + * + * No provider-imposed timeout: many methods (`personal_sign`, + * `eth_sendTransaction`, `wallet_requestExecutionPermissions`, …) wait on + * user deliberation, which has no deadline in EIP-1193. A pending request + * settles when the Branding Layer emits its callback, or rejects when + * {@link RpcHostClient.destroy} runs (e.g. `OWSProxy.destroy()`). + * Hosts that need a bound on a specific non-interactive call should apply + * their own `Promise.race` / abort at the call site. + */ +export class RpcHostClient { + private nextCallId = RPCCallId(1); + private readonly pending = new Map(); + + constructor( + private readonly child: Postmate.ParentAPI, + private readonly lifecycle?: RpcHostLifecycle, + ) { + child.on(OWS_RPC_CALLBACK_EVENT, (data: unknown) => { + this.handleCallback(data); + }); + } + + request(method: string, params: unknown): Promise { + const callId = RPCCallId(this.nextCallId++); + + this.lifecycle?.beforeRequest?.(); + + return new Promise((resolve, reject) => { + this.pending.set(callId, { + resolve: (value) => { + this.lifecycle?.afterRequest?.(); + resolve(value as T); + }, + reject: (error) => { + this.lifecycle?.afterRequest?.(); + reject(error); + }, + }); + + this.child.call(method, this.createRpcRequest(callId, method, params)); + }); + } + + destroy(): void { + for (const pending of this.pending.values()) { + pending.reject(new Error("RpcHostClient destroyed")); + } + this.pending.clear(); + } + + private handleCallback(data: unknown): void { + let envelope: RpcResponseEnvelope; + try { + envelope = deserializeRpcResponse(data); + } catch { + return; + } + + const pending = this.pending.get(envelope.callId); + if (!pending) { + return; + } + + this.pending.delete(envelope.callId); + + if (!envelope.success) { + if (envelope.error) { + pending.reject(OwsRpcError.fromPayload(envelope.error)); + return; + } + pending.reject(new OwsRpcError("RPC failed", -32_603)); + return; + } + + pending.resolve(envelope.result); + } + + private createRpcRequest( + callId: RPCCallId, + method: string, + params: unknown, + ): string { + const envelope: RpcRequestEnvelope = { callId, method, params }; + return serializeRpc(envelope); + } +} diff --git a/packages/ows-provider/src/solana/SolanaHostClient.ts b/packages/ows-provider/src/solana/SolanaHostClient.ts new file mode 100644 index 0000000..8d72c2c --- /dev/null +++ b/packages/ows-provider/src/solana/SolanaHostClient.ts @@ -0,0 +1,203 @@ +import { + SOLANA_WIRE_METHODS, + type IOpenWalletSolanaProvider, + type ISolanaGetAccountAddressParams, + type ISolanaSignAndSendTransactionParams, + type ISolanaSignAndSendTransactionResult, + type ISolanaSignInParams, + type ISolanaSignInResult, + type ISolanaSignMessageParams, + type ISolanaSignMessageResult, + type ISolanaSignTransactionParams, + type ISolanaSignTransactionResult, + type SolanaAccountAddress, +} from "@1shotapi/ows-types"; +import type { RpcHostClient } from "../rpc/RpcHostClient.js"; +import { + OwsSolanaStandardWallet, + type OwsSolanaStandardWalletOptions, +} from "./standard-wallet.js"; + +export type RegisterSolanaWalletOptions = OwsSolanaStandardWalletOptions; +export type { WalletIcon } from "@wallet-standard/base"; + +/** + * Host-side Solana namespace — forwards to Branding via Postmate wire methods. + * Opt-in Wallet Standard discovery via {@link registerSolanaWallet}. + */ +export class SolanaHostClient implements IOpenWalletSolanaProvider { + #standardWallet: OwsSolanaStandardWallet | undefined; + #registered = false; + #appReadyListener: ((event: Event) => void) | undefined; + readonly #unregisterFns: Array<() => void> = []; + + constructor(private readonly rpcClient: RpcHostClient) {} + + getAccountAddress( + params?: ISolanaGetAccountAddressParams, + ): Promise { + return this.rpcClient.request( + SOLANA_WIRE_METHODS.getAccountAddress, + params ?? {}, + ); + } + + signMessage( + params: ISolanaSignMessageParams, + ): Promise { + return this.rpcClient.request( + SOLANA_WIRE_METHODS.signMessage, + params, + ); + } + + signTransaction( + params: ISolanaSignTransactionParams, + ): Promise { + return this.rpcClient.request( + SOLANA_WIRE_METHODS.signTransaction, + params, + ); + } + + signAndSendTransaction( + params: ISolanaSignAndSendTransactionParams, + ): Promise { + return this.rpcClient.request( + SOLANA_WIRE_METHODS.signAndSendTransaction, + params, + ); + } + + signIn(params?: ISolanaSignInParams): Promise { + return this.rpcClient.request( + SOLANA_WIRE_METHODS.signIn, + params ?? {}, + ); + } + + /** + * Register this Solana namespace as a Wallet Standard wallet on the Host + * `window` (idempotent). Never called automatically from `OWSProxy.create`. + */ + registerSolanaWallet(options?: RegisterSolanaWalletOptions): void { + if (typeof window === "undefined") { + throw new Error( + "registerSolanaWallet requires a browser Host window", + ); + } + if (this.#registered) { + return; + } + this.#standardWallet ??= new OwsSolanaStandardWallet(this, options); + const wallet = this.#standardWallet; + + const callback = ({ + register, + }: { + register: (wallet: OwsSolanaStandardWallet) => () => void; + }): void => { + const unregister = register(wallet); + this.#unregisterFns.push(unregister); + }; + + try { + window.dispatchEvent( + new RegisterWalletEvent(callback as WindowRegisterWalletCallback), + ); + } catch (error) { + console.error( + "wallet-standard:register-wallet event could not be dispatched\n", + error, + ); + } + + this.#appReadyListener = (event: Event): void => { + callback( + (event as CustomEvent<{ register: (wallet: OwsSolanaStandardWallet) => () => void }>).detail, + ); + }; + try { + window.addEventListener( + "wallet-standard:app-ready", + this.#appReadyListener, + ); + } catch (error) { + console.error( + "wallet-standard:app-ready event listener could not be added\n", + error, + ); + } + + this.#registered = true; + } + + /** + * Unregister previously registered Wallet Standard entry points and clear + * the app-ready listener. Safe to call when never registered. + */ + unregisterSolanaWallet(): void { + for (const unregister of this.#unregisterFns.splice(0)) { + try { + unregister(); + } catch { + // ignore + } + } + if (this.#appReadyListener !== undefined && typeof window !== "undefined") { + window.removeEventListener( + "wallet-standard:app-ready", + this.#appReadyListener, + ); + this.#appReadyListener = undefined; + } + this.#registered = false; + } + + /** Tear down Standard registration (called from {@link OWSProxy.destroy}). */ + destroy(): void { + this.unregisterSolanaWallet(); + this.#standardWallet = undefined; + } +} + +type WindowRegisterWalletCallback = (api: { + register: (wallet: unknown) => () => void; +}) => void; + +/** + * Mirrors `@wallet-standard/wallet` `RegisterWalletEvent` so we can capture + * the `unregister` return value from each app's `register(wallet)`. + */ +class RegisterWalletEvent extends Event { + readonly #detail: WindowRegisterWalletCallback; + + get detail(): WindowRegisterWalletCallback { + return this.#detail; + } + + get type(): string { + return "wallet-standard:register-wallet"; + } + + constructor(callback: WindowRegisterWalletCallback) { + super("wallet-standard:register-wallet", { + bubbles: false, + cancelable: false, + composed: false, + }); + this.#detail = callback; + } + + override preventDefault(): void { + throw new Error("preventDefault cannot be called"); + } + + override stopImmediatePropagation(): void { + throw new Error("stopImmediatePropagation cannot be called"); + } + + override stopPropagation(): void { + throw new Error("stopPropagation cannot be called"); + } +} diff --git a/packages/ows-provider/src/solana/icon.ts b/packages/ows-provider/src/solana/icon.ts new file mode 100644 index 0000000..d5c78d7 --- /dev/null +++ b/packages/ows-provider/src/solana/icon.ts @@ -0,0 +1,5 @@ +import type { WalletIcon } from "@wallet-standard/base"; + +/** Default 1Shot Wallet icon (SVG data URI). */ +export const DEFAULT_SOLANA_WALLET_ICON = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iOCIgZmlsbD0iIzBCMTIyMCIvPjx0ZXh0IHg9IjE2IiB5PSIyMSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxNCIgZm9udC1mYW1pbHk9InN5c3RlbS11aSxzYW5zLXNlcmlmIiBmaWxsPSIjNUI4REVGIj4xPC90ZXh0Pjwvc3ZnPg==" as WalletIcon; diff --git a/packages/ows-provider/src/solana/standard-wallet.ts b/packages/ows-provider/src/solana/standard-wallet.ts new file mode 100644 index 0000000..7f8b0fa --- /dev/null +++ b/packages/ows-provider/src/solana/standard-wallet.ts @@ -0,0 +1,334 @@ +import { base58 } from "@scure/base"; +import { + ConversionUtils, + DomainStringSchema, + ISO8601DateTimeSchema, + SolanaAccountAddressSchema, + SolanaChainIdSchema, + UriStringSchema, + type DomainString, + type IOpenWalletSolanaProvider, + type ISolanaSignInParams, + type ISO8601DateTime, + type SolanaAccountAddress, + type SolanaChainId, + type UriString, +} from "@1shotapi/ows-types"; +import type { + IdentifierArray, + Wallet, + WalletAccount, + WalletIcon, + WalletVersion, +} from "@wallet-standard/base"; +import type { + StandardConnectFeature, + StandardConnectMethod, + StandardDisconnectFeature, + StandardDisconnectMethod, + StandardEventsFeature, + StandardEventsListeners, + StandardEventsOnMethod, +} from "@wallet-standard/features"; +import { + SolanaSignAndSendTransaction, + SolanaSignIn, + SolanaSignMessage, + SolanaSignTransaction, + type SolanaSignAndSendTransactionFeature, + type SolanaSignAndSendTransactionMethod, + type SolanaSignInFeature, + type SolanaSignInInput, + type SolanaSignInMethod, + type SolanaSignMessageFeature, + type SolanaSignMessageMethod, + type SolanaSignTransactionFeature, + type SolanaSignTransactionMethod, +} from "@solana/wallet-standard-features"; +import { DEFAULT_SOLANA_WALLET_ICON } from "./icon.js"; +import { attachSignatureAndEncode, extractMessageBytes } from "./tx-wire.js"; + +export const SOLANA_STANDARD_CHAINS = [ + "solana:mainnet", + "solana:devnet", +] as const satisfies IdentifierArray; + +const ACCOUNT_FEATURES = [ + SolanaSignAndSendTransaction, + SolanaSignTransaction, + SolanaSignMessage, + SolanaSignIn, +] as const satisfies IdentifierArray; + +export type OwsSolanaStandardWalletOptions = { + name?: string; + icon?: WalletIcon; +}; + +type OwsSolanaFeatures = StandardConnectFeature & + StandardDisconnectFeature & + StandardEventsFeature & + SolanaSignMessageFeature & + SolanaSignInFeature & + SolanaSignTransactionFeature & + SolanaSignAndSendTransactionFeature; + +/** + * Wallet Standard `Wallet` that forwards Solana features to the OWS Branding + * wire via {@link IOpenWalletSolanaProvider}. + */ +export class OwsSolanaStandardWallet implements Wallet { + readonly version = "1.0.0" as const satisfies WalletVersion; + readonly name: string; + readonly icon: WalletIcon; + readonly chains = SOLANA_STANDARD_CHAINS; + readonly features: OwsSolanaFeatures; + + #accounts: readonly WalletAccount[] = []; + readonly #listeners: { + change: Set; + } = { change: new Set() }; + readonly #client: IOpenWalletSolanaProvider; + + constructor( + client: IOpenWalletSolanaProvider, + options?: OwsSolanaStandardWalletOptions, + ) { + this.#client = client; + this.name = options?.name ?? "1Shot Wallet"; + this.icon = options?.icon ?? DEFAULT_SOLANA_WALLET_ICON; + + this.features = { + "standard:connect": { + version: "1.0.0", + connect: this.#connect, + }, + "standard:disconnect": { + version: "1.0.0", + disconnect: this.#disconnect, + }, + "standard:events": { + version: "1.0.0", + on: this.#on, + }, + [SolanaSignMessage]: { + version: "1.1.0", + signMessage: this.#signMessage, + }, + [SolanaSignIn]: { + version: "1.1.0", + signIn: this.#signIn, + }, + [SolanaSignTransaction]: { + version: "1.0.0", + supportedTransactionVersions: ["legacy", 0], + signTransaction: this.#signTransaction, + }, + [SolanaSignAndSendTransaction]: { + version: "1.0.0", + supportedTransactionVersions: ["legacy", 0], + signAndSendTransaction: this.#signAndSendTransaction, + }, + }; + } + + get accounts(): readonly WalletAccount[] { + return this.#accounts; + } + + readonly #on: StandardEventsOnMethod = (event, listener) => { + this.#listeners[event].add(listener); + return (): void => { + this.#listeners[event].delete(listener); + }; + }; + + #emit(event: keyof StandardEventsListeners, ...args: Parameters): void { + for (const listener of this.#listeners[event]) { + listener.apply(null, args); + } + } + + #setAccounts(accounts: readonly WalletAccount[]): void { + this.#accounts = accounts; + this.#emit("change", { accounts: this.accounts }); + } + + readonly #connect: StandardConnectMethod = async () => { + const address = await this.#client.getAccountAddress(); + const account = this.#toWalletAccount(address); + this.#setAccounts([account]); + return { accounts: this.accounts }; + }; + + readonly #disconnect: StandardDisconnectMethod = async () => { + this.#setAccounts([]); + }; + + readonly #signMessage: SolanaSignMessageMethod = async (...inputs) => { + const messages = inputs.map((input) => + ConversionUtils.bytesToBase64(input.message), + ); + const { signatures } = await this.#client.signMessage({ messages }); + if (signatures.length !== inputs.length) { + throw new Error( + `solana.signMessage returned ${signatures.length} signatures for ${inputs.length} messages`, + ); + } + return inputs.map((input, i) => ({ + signedMessage: input.message, + signature: base58.decode(signatures[i]!), + signatureType: "ed25519" as const, + })); + }; + + readonly #signIn: SolanaSignInMethod = async (...inputs) => { + if (inputs.length === 0) { + const result = await this.#client.signIn(); + const account = this.#toWalletAccount(result.account); + this.#setAccounts([account]); + return [ + { + account, + signedMessage: ConversionUtils.base64ToBytes(result.signedMessage), + signature: base58.decode(result.signature), + signatureType: "ed25519" as const, + }, + ]; + } + + const outputs = []; + for (const input of inputs) { + const result = await this.#client.signIn(mapSignInInput(input)); + const account = this.#toWalletAccount(result.account); + this.#setAccounts([account]); + outputs.push({ + account, + signedMessage: ConversionUtils.base64ToBytes(result.signedMessage), + signature: base58.decode(result.signature), + signatureType: "ed25519" as const, + }); + } + return outputs; + }; + + readonly #signTransaction: SolanaSignTransactionMethod = async (...inputs) => { + const decoded = inputs.map((input) => + extractMessageBytes(input.transaction), + ); + const { signatures } = await this.#client.signTransaction({ + transactions: decoded.map((d) => + ConversionUtils.bytesToBase64(d.messageBytes), + ), + }); + if (signatures.length !== inputs.length) { + throw new Error( + `solana.signTransaction returned ${signatures.length} signatures for ${inputs.length} transactions`, + ); + } + return inputs.map((input, i) => { + const sigBytes = base58.decode(signatures[i]!); + return { + signedTransaction: attachSignatureAndEncode( + decoded[i]!.transaction, + input.account.address, + sigBytes, + ), + }; + }); + }; + + readonly #signAndSendTransaction: SolanaSignAndSendTransactionMethod = + async (...inputs) => { + const outputs = []; + for (const input of inputs) { + const { messageBytes } = extractMessageBytes(input.transaction); + const chainId = parseOptionalSolanaChainId(input.chain); + const { signature } = await this.#client.signAndSendTransaction({ + transaction: ConversionUtils.bytesToBase64(messageBytes), + ...(chainId !== undefined ? { chainId } : {}), + }); + outputs.push({ signature: base58.decode(signature) }); + } + return outputs; + }; + + #toWalletAccount(address: SolanaAccountAddress): WalletAccount { + const publicKey = base58.decode(address); + if (publicKey.length !== 32) { + throw new Error("Solana account address must decode to 32 bytes"); + } + return { + address, + publicKey, + chains: SOLANA_STANDARD_CHAINS, + features: ACCOUNT_FEATURES, + }; + } +} + +function mapSignInInput(input: SolanaSignInInput): ISolanaSignInParams { + const params: ISolanaSignInParams = {}; + if (input.domain !== undefined) { + params.domain = parseDomain(input.domain); + } + if (input.address !== undefined) { + params.address = SolanaAccountAddressSchema.parse(input.address); + } + if (input.statement !== undefined) { + params.statement = input.statement; + } + if (input.uri !== undefined) { + params.uri = parseUri(input.uri); + } + if (input.version !== undefined) { + params.version = input.version; + } + if (input.chainId !== undefined) { + const chainId = parseOptionalSolanaChainId(input.chainId); + if (chainId !== undefined) { + params.chainId = chainId; + } + } + if (input.nonce !== undefined) { + params.nonce = input.nonce; + } + if (input.issuedAt !== undefined) { + params.issuedAt = parseIso(input.issuedAt); + } + if (input.expirationTime !== undefined) { + params.expirationTime = parseIso(input.expirationTime); + } + if (input.notBefore !== undefined) { + params.notBefore = parseIso(input.notBefore); + } + if (input.requestId !== undefined) { + params.requestId = input.requestId; + } + if (input.resources !== undefined) { + params.resources = input.resources.map(parseUri); + } + return params; +} + +function parseOptionalSolanaChainId( + value: string | undefined, +): SolanaChainId | undefined { + if (value === undefined) { + return undefined; + } + const parsed = SolanaChainIdSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} + +function parseDomain(value: string): DomainString { + return DomainStringSchema.parse(value); +} + +function parseUri(value: string): UriString { + return UriStringSchema.parse(value); +} + +function parseIso(value: string): ISO8601DateTime { + return ISO8601DateTimeSchema.parse(value); +} diff --git a/packages/ows-provider/src/solana/tx-wire.ts b/packages/ows-provider/src/solana/tx-wire.ts new file mode 100644 index 0000000..0c71927 --- /dev/null +++ b/packages/ows-provider/src/solana/tx-wire.ts @@ -0,0 +1,39 @@ +import { address, type Address } from "@solana/addresses"; +import type { SignatureBytes } from "@solana/keys"; +import { + getTransactionDecoder, + getTransactionEncoder, + type Transaction, +} from "@solana/transactions"; + +/** + * Decode a Wallet Standard serialized transaction, return message bytes for + * Branding `solana.signTransaction`, then merge the Ed25519 signature and + * re-encode to wire bytes. + */ +export function extractMessageBytes(serializedTransaction: Uint8Array): { + transaction: Transaction; + messageBytes: Uint8Array; +} { + const transaction = getTransactionDecoder().decode(serializedTransaction); + return { + transaction, + messageBytes: new Uint8Array(transaction.messageBytes), + }; +} + +export function attachSignatureAndEncode( + transaction: Transaction, + signerAddress: string, + signatureBytes: Uint8Array, +): Uint8Array { + const key = address(signerAddress) as Address; + const next: Transaction = { + messageBytes: transaction.messageBytes, + signatures: { + ...transaction.signatures, + [key]: signatureBytes as SignatureBytes, + }, + }; + return new Uint8Array(getTransactionEncoder().encode(next)); +} diff --git a/packages/ows-provider/test/demo-fixtures.ts b/packages/ows-provider/test/demo-fixtures.ts new file mode 100644 index 0000000..058fe59 --- /dev/null +++ b/packages/ows-provider/test/demo-fixtures.ts @@ -0,0 +1,79 @@ +import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"; +import { + CredentialCryptoUtils, + type CredentialClaimName, +} from "@1shotapi/ows-types"; + +/** TEST ONLY — matches examples/shared demo issuer key. */ +export const DEMO_ISSUER_PRIVATE_JWK: JsonWebKey = { + crv: "Ed25519", + d: "pcLAFtmJw-OtcPm7taGhEDocf63HfBk5TMXWSoF6rvw", + x: "ObRX6jKS0AbsXx3ICSIiozuqzYUXXrQhlj4GkeqXfbs", + kty: "OKP", +}; + +export const DEMO_ISSUER_PUBLIC_JWK: JsonWebKey = { + crv: "Ed25519", + x: "ObRX6jKS0AbsXx3ICSIiozuqzYUXXrQhlj4GkeqXfbs", + kty: "OKP", +}; + +export const DEMO_HOLDER_PRIVATE_JWK: JsonWebKey = { + crv: "Ed25519", + d: "G8_8M9RjEA5L5NIeAoB24C9yOb8KPuRoh9y7SidoXJA", + x: "3dmWOAMTtkMxm88aJ9QhlK5SWimNXt6-WTsI4eFHwC8", + kty: "OKP", +}; + +export const DEMO_HOLDER_PUBLIC_JWK: JsonWebKey = { + crv: "Ed25519", + x: "3dmWOAMTtkMxm88aJ9QhlK5SWimNXt6-WTsI4eFHwC8", + kty: "OKP", +}; + +export async function issueDemoSdJwtVc(input: { + issuer: string; + vct: string; + claims: Record; + disclosableClaims: CredentialClaimName[]; + holderPublicKeyJwk: JsonWebKey; +}): Promise { + const sdjwt = new SDJwtVcInstance({ + signer: CredentialCryptoUtils.createEd25519Signer(DEMO_ISSUER_PRIVATE_JWK), + signAlg: "EdDSA", + verifier: CredentialCryptoUtils.createEd25519Verifier(DEMO_ISSUER_PUBLIC_JWK), + hasher: CredentialCryptoUtils.hasher, + hashAlg: "sha-256", + saltGenerator: CredentialCryptoUtils.saltGenerator, + }); + + const iat = Math.floor(Date.now() / 1000); + return sdjwt.issue( + { + iss: input.issuer, + iat, + vct: input.vct, + cnf: { jwk: input.holderPublicKeyJwk }, + ...input.claims, + } as Parameters[0], + { _sd: input.disclosableClaims.map(String) } as Parameters< + SDJwtVcInstance["issue"] + >[1], + ); +} + +export function createDemoHolderSigner() { + const publicJwk = { ...DEMO_HOLDER_PRIVATE_JWK }; + delete publicJwk.d; + return { + async publicKeyJwk() { + return publicJwk; + }, + async signKbJwt(unsignedJwt: string) { + return CredentialCryptoUtils.signEd25519( + DEMO_HOLDER_PRIVATE_JWK, + unsignedJwt, + ); + }, + }; +} diff --git a/packages/ows-provider/test/display-host.test.ts b/packages/ows-provider/test/display-host.test.ts new file mode 100644 index 0000000..351fc5a --- /dev/null +++ b/packages/ows-provider/test/display-host.test.ts @@ -0,0 +1,511 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + DisplayRequestId, + OWS_DISPLAY_READY_MODEL_METHOD, + OWS_HIDE_READY_MODEL_METHOD, + OWS_RELEASE_DISPLAY_EVENT, + OWS_REQUEST_DISPLAY_EVENT, + OWS_REQUEST_HIDE_EVENT, + serializeRpc, +} from "@1shotapi/ows-types"; +import { + applyHiddenWalletContainerStyles, + applyHiddenWalletFrameStyles, + DisplayHostHandler, + EWalletPresentationMode, + releaseWalletFrameFocus, +} from "../src/display/DisplayHostHandler.ts"; + +class MockHTMLIFrameElement {} + +function createMockParent() { + const listeners = new Map void>(); + const calls: Array<{ method: string; data: unknown }> = []; + const frame = { + className: "", + style: { + length: 0, + item: () => "", + getPropertyValue: () => "", + setProperty: function (this: Record, name: string, value: string) { + this[name] = value; + }, + removeProperty: function (this: Record, name: string) { + delete this[name]; + }, + } as unknown as CSSStyleDeclaration, + parentElement: { + className: "", + inert: false, + style: { + length: 0, + item: () => "", + getPropertyValue: () => "", + setProperty: function (this: Record, name: string, value: string) { + this[name] = value; + }, + removeProperty: function (this: Record, name: string) { + delete this[name]; + }, + } as unknown as CSSStyleDeclaration, + getAttribute(name: string) { + return name === "aria-hidden" ? "true" : null; + }, + setAttribute() {}, + removeAttribute() {}, + }, + focus() {}, + blur() {}, + contentWindow: { focus() {}, blur() {} }, + removeAttribute() {}, + }; + + const parent = { + frame, + on(event: string, cb: (data: unknown) => void) { + listeners.set(event, cb); + }, + call(method: string, data: unknown) { + calls.push({ method, data }); + }, + }; + + return { + parent: parent as never, + listeners, + calls, + frame, + }; +} + +function ensureWindow(): { + innerWidth: number; + innerHeight: number; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; +} { + if (typeof (globalThis as { window?: unknown }).window === "undefined") { + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: { + innerWidth: 1024, + innerHeight: 768, + addEventListener() {}, + removeEventListener() {}, + }, + }); + } + return (globalThis as unknown as { window: ReturnType }) + .window; +} + +function stubViewport(width: number, height: number): () => void { + const win = ensureWindow(); + const previous = { + innerWidth: Object.getOwnPropertyDescriptor(win, "innerWidth"), + innerHeight: Object.getOwnPropertyDescriptor(win, "innerHeight"), + }; + Object.defineProperty(win, "innerWidth", { + configurable: true, + get: () => width, + }); + Object.defineProperty(win, "innerHeight", { + configurable: true, + get: () => height, + }); + return () => { + if (previous.innerWidth) { + Object.defineProperty(win, "innerWidth", previous.innerWidth); + } + if (previous.innerHeight) { + Object.defineProperty(win, "innerHeight", previous.innerHeight); + } + }; +} + +describe("DisplayHostHandler", () => { + before(() => { + // @ts-expect-error test shim for instanceof checks in Node + globalThis.HTMLIFrameElement = MockHTMLIFrameElement; + }); + + after(() => { + // @ts-expect-error cleanup test shim + delete globalThis.HTMLIFrameElement; + }); + + it("shows lower-right flyout when viewport fits wallet size + 32px", () => { + const restore = stubViewport(800, 700); + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + new DisplayHostHandler(mock.parent); + + const displayId = DisplayRequestId("10"); + mock.listeners.get(OWS_REQUEST_DISPLAY_EVENT)?.( + serializeRpc({ displayId }), + ); + + assert.equal(mock.calls.length, 1); + assert.equal(mock.calls[0]?.method, OWS_DISPLAY_READY_MODEL_METHOD); + assert.equal( + JSON.parse(mock.calls[0]!.data as string).displayId, + displayId, + ); + + const frameStyle = mock.frame.style as unknown as Record; + assert.equal(frameStyle.width, "100%"); + assert.equal(frameStyle.height, "100%"); + assert.equal(frameStyle.position, "static"); + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle.position, "fixed"); + assert.equal(containerStyle.bottom, "16px"); + assert.equal(containerStyle.right, "16px"); + assert.equal(containerStyle.opacity, "1"); + assert.equal(containerStyle.width, "360px"); + assert.equal(containerStyle.height, "600px"); + restore(); + }); + + it("uses walletSizeX / walletSizeY when provided", () => { + const restore = stubViewport(800, 700); + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + new DisplayHostHandler(mock.parent, { + walletSizeX: 320, + walletSizeY: 480, + }); + + mock.listeners.get(OWS_REQUEST_DISPLAY_EVENT)?.( + serializeRpc({ + displayId: DisplayRequestId("11"), + }), + ); + + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle.width, "320px"); + assert.equal(containerStyle.height, "480px"); + restore(); + }); + + it("uses full-screen drawer when viewport is narrower than wallet width + 32px", () => { + const restore = stubViewport(360, 900); + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + new DisplayHostHandler(mock.parent, { + walletSizeX: 360, + walletSizeY: 600, + }); + + mock.listeners.get(OWS_REQUEST_DISPLAY_EVENT)?.( + serializeRpc({ displayId: DisplayRequestId("12") }), + ); + + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle.position, "fixed"); + assert.equal(containerStyle.inset, "0"); + assert.equal(containerStyle.width, "100%"); + assert.equal(containerStyle.height, "100%"); + assert.equal(containerStyle.bottom, "0"); + assert.equal(containerStyle.right, "0"); + restore(); + }); + + it("keeps flyout on a short-but-wide desktop viewport", () => { + // 1080p @ 150% DPI with browser chrome often yields height < 632. + const restore = stubViewport(1280, 600); + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + new DisplayHostHandler(mock.parent, { + walletSizeX: 360, + walletSizeY: 600, + }); + + mock.listeners.get(OWS_REQUEST_DISPLAY_EVENT)?.( + serializeRpc({ displayId: DisplayRequestId("13") }), + ); + + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle.width, "360px"); + assert.equal(containerStyle.height, "600px"); + assert.equal(containerStyle.bottom, "16px"); + assert.equal(containerStyle.right, "16px"); + restore(); + }); + + it("shouldUseDrawer is true only when width cannot fit margin", () => { + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + const handler = new DisplayHostHandler(mock.parent, { + walletSizeX: 360, + walletSizeY: 600, + }); + + // Needs width ≥ 392 (size + 16px each side) + const restoreNarrow = stubViewport(391, 900); + assert.equal(handler.shouldUseDrawer(), true); + restoreNarrow(); + + const restoreShortWide = stubViewport(1280, 500); + assert.equal(handler.shouldUseDrawer(), false); + restoreShortWide(); + + const restoreFit = stubViewport(392, 500); + assert.equal(handler.shouldUseDrawer(), false); + restoreFit(); + + handler.destroy(); + }); + + it("hides on requestHide and notifies child", async () => { + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + new DisplayHostHandler(mock.parent); + + mock.listeners.get(OWS_REQUEST_HIDE_EVENT)?.(serializeRpc({})); + + await Promise.resolve(); + assert.equal(mock.calls.length, 1); + assert.equal(mock.calls[0]?.method, OWS_HIDE_READY_MODEL_METHOD); + }); + + it("applyHiddenWalletContainerStyles collapses container before iframe exists", () => { + const attrs = new Map(); + const container = { + className: "wallet-container", + inert: false, + style: { + length: 0, + item: () => "", + getPropertyValue: () => "", + setProperty: function (this: Record, name: string, value: string) { + this[name] = value; + }, + removeProperty: function (this: Record, name: string) { + delete this[name]; + }, + } as unknown as CSSStyleDeclaration, + setAttribute(name: string, value: string) { + attrs.set(name, value); + }, + removeAttribute(name: string) { + attrs.delete(name); + }, + getAttribute(name: string) { + return attrs.get(name) ?? null; + }, + }; + + applyHiddenWalletContainerStyles(container as never); + + const style = container.style as unknown as Record; + assert.equal(style.width, "0"); + assert.equal(style.height, "0"); + assert.equal(style.opacity, "0"); + assert.equal(style["clip-path"], "inset(50%)"); + assert.equal(style["pointer-events"], "none"); + assert.equal(container.inert, true); + assert.equal(attrs.get("aria-hidden"), "true"); + }); + + it("releaseWalletFrameFocus moves activeElement off the iframe", () => { + const previousDocument = (globalThis as { document?: Document }).document; + const body = { + appendChild(node: Node) { + return node; + }, + }; + let active: { focus?: () => void; blur?: () => void } | null = null; + const frame = { + blur() { + // Simulated iframe blur that does not clear activeElement (Chrome + WebAuthn). + }, + contains() { + return false; + }, + }; + active = frame; + + Object.defineProperty(globalThis, "document", { + configurable: true, + writable: true, + value: { + body, + get activeElement() { + return active; + }, + createElement(tag: string) { + assert.equal(tag, "span"); + const sink = { + tabIndex: 0, + style: { cssText: "" }, + setAttribute() {}, + focus() { + active = sink; + }, + remove() { + active = body; + }, + }; + return sink; + }, + }, + }); + + try { + releaseWalletFrameFocus(frame as never); + assert.notEqual(active, frame); + } finally { + if (previousDocument === undefined) { + delete (globalThis as { document?: Document }).document; + } else { + Object.defineProperty(globalThis, "document", { + configurable: true, + writable: true, + value: previousDocument, + }); + } + } + }); + + it("applyHiddenWalletFrameStyles hides iframe at append time", () => { + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + + applyHiddenWalletFrameStyles(mock.frame as never); + + const frameStyle = mock.frame.style as unknown as Record; + assert.equal(frameStyle.opacity, "0"); + assert.equal(frameStyle["pointer-events"], "none"); + assert.equal(frameStyle.width, "100%"); + }); + + it("initializeHidden collapses a full-screen host container", () => { + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + containerStyle.inset = "0"; + containerStyle.width = "100%"; + containerStyle.height = "100%"; + + const handler = new DisplayHostHandler(mock.parent); + handler.initializeHidden(); + + assert.equal(containerStyle.width, "0"); + assert.equal(containerStyle.height, "0"); + assert.equal(containerStyle["clip-path"], "inset(50%)"); + assert.equal(containerStyle.opacity, "0"); + assert.equal(containerStyle["pointer-events"], "none"); + handler.destroy(); + }); + + it("prepareForRpcAccess shows 1×1 passthrough when panel is hidden", () => { + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + const handler = new DisplayHostHandler(mock.parent); + + handler.prepareForRpcAccess(); + + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle["clip-path"], "none"); + assert.equal(containerStyle.overflow, "hidden"); + assert.equal(containerStyle.width, "1px"); + assert.equal(containerStyle.height, "1px"); + handler.destroy(); + }); + + it("prepareForRpcAccess keeps a visible host panel instead of collapsing to 1×1", () => { + const restore = stubViewport(800, 700); + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + const handler = new DisplayHostHandler(mock.parent, { + walletSizeX: 360, + walletSizeY: 600, + }); + + handler.show(); + handler.prepareForRpcAccess(); + + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle.width, "360px"); + assert.equal(containerStyle.height, "600px"); + assert.equal(containerStyle.opacity, "1"); + handler.destroy(); + restore(); + }); + + it("inline mode fills create container and ignores host hide", async () => { + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + const handler = new DisplayHostHandler(mock.parent, { + presentationMode: EWalletPresentationMode.Inline, + }); + handler.initializeInlineVisible(); + + const containerStyle = mock.frame.parentElement.style as unknown as Record< + string, + string + >; + assert.equal(containerStyle.position, "absolute"); + assert.equal(containerStyle.width, "100%"); + assert.equal(containerStyle.height, "100%"); + + handler.hide(); + assert.equal(containerStyle.width, "100%"); + assert.equal(containerStyle.opacity, "1"); + + mock.listeners.get(OWS_REQUEST_HIDE_EVENT)?.(serializeRpc({})); + await Promise.resolve(); + assert.equal(mock.calls[0]?.method, OWS_HIDE_READY_MODEL_METHOD); + assert.equal(containerStyle.width, "100%"); + assert.equal(containerStyle.opacity, "1"); + handler.destroy(); + }); + + it("allows a new display session after release", () => { + const restore = stubViewport(800, 700); + const mock = createMockParent(); + Object.setPrototypeOf(mock.frame, MockHTMLIFrameElement.prototype); + const handler = new DisplayHostHandler(mock.parent); + const displayId = DisplayRequestId("2"); + + mock.listeners.get(OWS_REQUEST_DISPLAY_EVENT)?.( + serializeRpc({ displayId }), + ); + + mock.listeners.get(OWS_RELEASE_DISPLAY_EVENT)?.( + serializeRpc({ displayId }), + ); + + mock.calls.length = 0; + mock.listeners.get(OWS_REQUEST_DISPLAY_EVENT)?.( + serializeRpc({ displayId: DisplayRequestId("3") }), + ); + + assert.equal(mock.calls.length, 1); + handler.destroy(); + restore(); + }); +}); diff --git a/packages/ows-provider/test/ows-proxy.test.ts b/packages/ows-provider/test/ows-proxy.test.ts new file mode 100644 index 0000000..7d6c954 --- /dev/null +++ b/packages/ows-provider/test/ows-proxy.test.ts @@ -0,0 +1,211 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { EVMAccountAddress, OWS_RPC_CALLBACK_EVENT, RPCCallId } from "@1shotapi/ows-types"; +import { RpcHostClient } from "../src/rpc/RpcHostClient.ts"; +import { EIP1193Provider } from "../src/eip1193/EIP1193Provider.ts"; + +describe("RpcHostClient", () => { + afterEach(() => { + // @ts-expect-error cleanup + delete globalThis.window; + }); + + it("resolves RPC callbacks from child emit", async () => { + const listeners = new Map void>(); + const child = { + on(event: string, cb: (data: unknown) => void) { + listeners.set(event, cb); + }, + call(method: string, data: string) { + const request = JSON.parse(data) as { callId: RPCCallId; params: unknown }; + const response = { + callId: request.callId, + success: true, + result: { method, params: request.params }, + }; + listeners.get(OWS_RPC_CALLBACK_EVENT)?.(JSON.stringify(response)); + }, + }; + + const client = new RpcHostClient(child as never); + const result = await client.request("customMethod", { foo: 1 }); + assert.deepEqual(result, { method: "customMethod", params: { foo: 1 } }); + client.destroy(); + }); + + it("rejects unimplemented errors from callback", async () => { + const listeners = new Map void>(); + const child = { + on(event: string, cb: (data: unknown) => void) { + listeners.set(event, cb); + }, + call(_method: string, data: string) { + const request = JSON.parse(data) as { callId: RPCCallId }; + listeners.get(OWS_RPC_CALLBACK_EVENT)?.( + JSON.stringify({ + callId: request.callId, + success: false, + error: { code: -32_601, message: "not implemented" }, + }), + ); + }, + }; + + const client = new RpcHostClient(child as never); + await assert.rejects( + client.request("missing", null), + (error: Error) => error.name === "OwsUnimplementedError", + ); + client.destroy(); + }); + + it("stays pending until a late callback resolves it (no RPC timeout)", async () => { + const listeners = new Map void>(); + let respond: (() => void) | undefined; + const child = { + on(event: string, cb: (data: unknown) => void) { + listeners.set(event, cb); + }, + call(_method: string, data: string) { + const request = JSON.parse(data) as { callId: RPCCallId }; + respond = () => { + listeners.get(OWS_RPC_CALLBACK_EVENT)?.( + JSON.stringify({ + callId: request.callId, + success: true, + result: "late-ok", + }), + ); + }; + }, + }; + + const client = new RpcHostClient(child as never); + const promise = client.request("personal_sign", ["0x6869"]); + let settled = false; + void promise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(settled, false); + respond?.(); + assert.equal(await promise, "late-ok"); + client.destroy(); + }); + + it("destroy rejects pending requests", async () => { + const child = { + on() {}, + call() {}, + }; + const client = new RpcHostClient(child as never); + const promise = client.request("personal_sign", ["0x6869"]); + client.destroy(); + await assert.rejects(promise, /RpcHostClient destroyed/); + }); +}); + +describe("EIP1193Provider", () => { + it("normalizes missing params to an empty array", async () => { + const provider = new EIP1193Provider(async (method, params) => { + return { method, params }; + }); + + const result = await provider.request({ method: "eth_chainId" }); + assert.deepEqual(result, { method: "eth_chainId", params: [] }); + }); + + it("forwards explicit params arrays", async () => { + const provider = new EIP1193Provider(async (_method, params) => params); + + const result = await provider.request({ + method: "personal_sign", + params: [ + "0xhi", + EVMAccountAddress("0x0000000000000000000000000000000000000001"), + ], + }); + + assert.deepEqual(result, [ + "0xhi", + "0x0000000000000000000000000000000000000001", + ]); + }); + + it("infers EVMAccountAddress[] for eth_requestAccounts", async () => { + const address = EVMAccountAddress( + "0x00000000000000000000000000000000000000ab", + ); + const provider = new EIP1193Provider(async () => [address]); + + const accounts = await provider.request({ method: "eth_requestAccounts" }); + assert.equal(accounts[0], address); + }); + + it("emits provider events to on() listeners", () => { + const provider = new EIP1193Provider(async () => null); + const seen: unknown[] = []; + provider.on("chainChanged", (chainId) => { + seen.push(chainId); + }); + provider.emit("chainChanged", "0x2105"); + assert.deepEqual(seen, ["0x2105"]); + }); +}); + +describe("BitcoinHostClient", () => { + it("forwards getAccountAddresses through RpcHostClient with namespaced wire method", async () => { + const { BitcoinHostClient } = await import( + "../src/bitcoin/BitcoinHostClient.ts" + ); + const sentCalls: Array<{ method: string; params: unknown }> = []; + const mockRpcClient = { + request: async (method: string, params: unknown): Promise => { + sentCalls.push({ method, params }); + return [ + { + address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + intention: "payment", + }, + ] as T; + }, + }; + + const client = new BitcoinHostClient(mockRpcClient as never); + const result = await client.getAccountAddresses(); + + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0]?.method, "bitcoin.getAccountAddresses"); + assert.deepEqual(sentCalls[0]?.params, {}); + assert.equal(result.length, 1); + assert.equal(result[0]?.address, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); + }); +}); + +describe("SolanaHostClient", () => { + it("forwards getAccountAddress through RpcHostClient with namespaced wire method", async () => { + const { SolanaHostClient } = await import( + "../src/solana/SolanaHostClient.ts" + ); + const sentCalls: Array<{ method: string; params: unknown }> = []; + const mockRpcClient = { + request: async (method: string, params: unknown): Promise => { + sentCalls.push({ method, params }); + return "11111111111111111111111111111111" as T; + }, + }; + + const client = new SolanaHostClient(mockRpcClient as never); + const result = await client.getAccountAddress(); + + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0]?.method, "solana.getAccountAddress"); + assert.deepEqual(sentCalls[0]?.params, {}); + assert.equal(result, "11111111111111111111111111111111"); + }); +}); diff --git a/packages/ows-provider/test/scaffold.test.ts b/packages/ows-provider/test/scaffold.test.ts new file mode 100644 index 0000000..08b2cee --- /dev/null +++ b/packages/ows-provider/test/scaffold.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { OWSProxy } from "../src/index.ts"; + +describe("@1shotapi/ows-provider exports", () => { + it("exports OWSProxy factory", () => { + assert.equal(typeof OWSProxy.create, "function"); + }); +}); diff --git a/packages/ows-provider/test/sd-jwt-vc.test.ts b/packages/ows-provider/test/sd-jwt-vc.test.ts new file mode 100644 index 0000000..9786991 --- /dev/null +++ b/packages/ows-provider/test/sd-jwt-vc.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { CredentialClaimName, CredentialIssuer, PresentationUtils } from "@1shotapi/ows-types"; +import { verifySdJwtVcPresentation } from "../src/credentials/verify.js"; +import { + issueDemoSdJwtVc, + createDemoHolderSigner, + DEMO_HOLDER_PUBLIC_JWK, + DEMO_ISSUER_PUBLIC_JWK, +} from "./demo-fixtures.js"; + +const MOCK_KYC_ISSUER_ID = CredentialIssuer("https://kyc.demo.issuer.example"); + +describe("SD-JWT VC verify", () => { + it("issues, presents with kb+jwt, and verifies", async () => { + const holderSigner = createDemoHolderSigner(); + const holderJwk = await holderSigner.publicKeyJwk(); + + const subject = { + ageOver18: true, + country: "US", + }; + + const payload = await issueDemoSdJwtVc({ + issuer: MOCK_KYC_ISSUER_ID, + vct: "KycCredential", + claims: subject, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + holderPublicKeyJwk: holderJwk, + }); + + const built = await PresentationUtils.build({ + credential: { + credentialId: "cred_test" as never, + format: "sd-jwt-vc", + type: ["VerifiableCredential", "KycCredential"] as never, + issuer: MOCK_KYC_ISSUER_ID, + issuedAt: new Date().toISOString() as never, + payload, + semantic: { + type: ["VerifiableCredential", "KycCredential"] as never, + issuer: MOCK_KYC_ISSUER_ID, + credentialSubject: subject, + }, + }, + definition: { + id: "test", + verifier: { id: "https://verifier.example" as never, name: "Verifier" }, + requestedClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + nonce: "test-nonce", + audience: "https://verifier.example", + }, + holderSigner, + }); + + const verified = await verifySdJwtVcPresentation({ + presentation: built.presentation, + issuerPublicKeyJwk: DEMO_ISSUER_PUBLIC_JWK, + holderPublicKeyJwk: DEMO_HOLDER_PUBLIC_JWK, + nonce: "test-nonce", + audience: "https://verifier.example", + }); + assert.equal(verified.valid, true); + assert.equal(verified.payload?.country, "US"); + }); + + it("rejects kb+jwt audience mismatch", async () => { + const holderSigner = createDemoHolderSigner(); + const holderJwk = await holderSigner.publicKeyJwk(); + + const subject = { + ageOver18: true, + country: "US", + }; + + const payload = await issueDemoSdJwtVc({ + issuer: MOCK_KYC_ISSUER_ID, + vct: "KycCredential", + claims: subject, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + holderPublicKeyJwk: holderJwk, + }); + + const built = await PresentationUtils.build({ + credential: { + credentialId: "cred_test" as never, + format: "sd-jwt-vc", + type: ["VerifiableCredential", "KycCredential"] as never, + issuer: MOCK_KYC_ISSUER_ID, + issuedAt: new Date().toISOString() as never, + payload, + semantic: { + type: ["VerifiableCredential", "KycCredential"] as never, + issuer: MOCK_KYC_ISSUER_ID, + credentialSubject: subject, + }, + }, + definition: { + id: "test", + verifier: { id: "https://verifier.example" as never, name: "Verifier" }, + requestedClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + nonce: "test-nonce", + audience: "https://verifier.example", + }, + holderSigner, + }); + + const verified = await verifySdJwtVcPresentation({ + presentation: built.presentation, + issuerPublicKeyJwk: DEMO_ISSUER_PUBLIC_JWK, + holderPublicKeyJwk: DEMO_HOLDER_PUBLIC_JWK, + nonce: "test-nonce", + audience: "https://wrong-audience.example", + }); + assert.equal(verified.valid, false); + assert.ok(verified.reasons.some((r) => /audience/i.test(r))); + }); +}); diff --git a/packages/ows-provider/test/solana-standard-wallet.test.ts b/packages/ows-provider/test/solana-standard-wallet.test.ts new file mode 100644 index 0000000..73c61a6 --- /dev/null +++ b/packages/ows-provider/test/solana-standard-wallet.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { base58 } from "@scure/base"; +import { ConversionUtils } from "@1shotapi/ows-types"; +import { SolanaSignMessage } from "@solana/wallet-standard-features"; +import type { WalletAccount } from "@wallet-standard/base"; +import { SolanaHostClient } from "../src/solana/SolanaHostClient.ts"; +import { OwsSolanaStandardWallet } from "../src/solana/standard-wallet.ts"; + +const SYSTEM_PROGRAM = "11111111111111111111111111111111"; +const FAKE_SIG = base58.encode(new Uint8Array(64).fill(7)); + +function mockRpc( + handler: (method: string, params: unknown) => unknown, +): { request: (method: string, params: unknown) => Promise } { + return { + request: async (method: string, params: unknown): Promise => + handler(method, params) as T, + }; +} + +describe("OwsSolanaStandardWallet", () => { + it("connect populates accounts from getAccountAddress", async () => { + const client = { + getAccountAddress: async () => SYSTEM_PROGRAM, + signMessage: async () => ({ signatures: [] }), + signTransaction: async () => ({ signatures: [] }), + signAndSendTransaction: async () => ({ signature: FAKE_SIG }), + signIn: async () => ({ + account: SYSTEM_PROGRAM, + signedMessage: ConversionUtils.bytesToBase64( + new TextEncoder().encode("hi"), + ), + signature: FAKE_SIG, + }), + }; + + const wallet = new OwsSolanaStandardWallet(client as never); + assert.equal(wallet.accounts.length, 0); + + const accountLengths: number[] = []; + wallet.features["standard:events"].on("change", ({ accounts }) => { + accountLengths.push(accounts?.length ?? -1); + }); + + const { accounts } = await wallet.features["standard:connect"].connect(); + assert.equal(accounts.length, 1); + assert.equal(accounts[0]?.address, SYSTEM_PROGRAM); + assert.equal(accounts[0]?.publicKey.length, 32); + assert.equal(wallet.accounts.length, 1); + assert.deepEqual(accountLengths, [1]); + + await wallet.features["standard:disconnect"].disconnect(); + assert.equal(wallet.accounts.length, 0); + assert.deepEqual(accountLengths, [1, 0]); + }); + + it("signMessage base64-encodes wire payloads and decodes signatures", async () => { + const message = new TextEncoder().encode("hello solana"); + const sent: unknown[] = []; + const client = { + getAccountAddress: async () => SYSTEM_PROGRAM, + signMessage: async (params: { messages: string[] }) => { + sent.push(params); + return { signatures: [FAKE_SIG] }; + }, + signTransaction: async () => ({ signatures: [] }), + signAndSendTransaction: async () => ({ signature: FAKE_SIG }), + signIn: async () => { + throw new Error("unused"); + }, + }; + + const wallet = new OwsSolanaStandardWallet(client as never); + const account = ( + await wallet.features["standard:connect"].connect() + ).accounts[0] as WalletAccount; + + const [out] = await wallet.features[SolanaSignMessage].signMessage({ + account, + message, + }); + + assert.deepEqual(sent[0], { + messages: [ConversionUtils.bytesToBase64(message)], + }); + assert.deepEqual(out?.signedMessage, message); + assert.deepEqual(out?.signature, base58.decode(FAKE_SIG)); + assert.equal(out?.signatureType, "ed25519"); + }); +}); + +describe("SolanaHostClient.registerSolanaWallet", () => { + it("dispatches wallet-standard:register-wallet (idempotent)", () => { + const g = globalThis as typeof globalThis & { + window?: Window & typeof globalThis; + }; + const previous = g.window; + + const listeners = new Map>(); + const fakeWindow = { + dispatchEvent(event: Event): boolean { + const set = listeners.get(event.type); + if (set) { + for (const listener of set) { + listener(event); + } + } + return true; + }, + addEventListener(type: string, listener: EventListener): void { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + removeEventListener(type: string, listener: EventListener): void { + listeners.get(type)?.delete(listener); + }, + } as unknown as Window & typeof globalThis; + g.window = fakeWindow; + + try { + let registerCalls = 0; + fakeWindow.addEventListener( + "wallet-standard:register-wallet", + ((event: Event) => { + const detail = (event as { detail: (api: unknown) => void }).detail; + detail({ + register: () => { + registerCalls += 1; + return () => { + registerCalls -= 1; + }; + }, + }); + }) as EventListener, + ); + + const client = new SolanaHostClient( + mockRpc(() => SYSTEM_PROGRAM) as never, + ); + client.registerSolanaWallet(); + assert.equal(registerCalls, 1); + + client.registerSolanaWallet(); + assert.equal(registerCalls, 1, "second register is idempotent"); + + client.unregisterSolanaWallet(); + assert.equal(registerCalls, 0); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(g, "window"); + } else { + g.window = previous; + } + } + }); +}); diff --git a/packages/ows-provider/tsconfig.json b/packages/ows-provider/tsconfig.json new file mode 100644 index 0000000..9deca52 --- /dev/null +++ b/packages/ows-provider/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["src/reference.ts/**"] +} diff --git a/packages/ows-signer-utils/CHANGELOG.md b/packages/ows-signer-utils/CHANGELOG.md new file mode 100644 index 0000000..96599e6 --- /dev/null +++ b/packages/ows-signer-utils/CHANGELOG.md @@ -0,0 +1,227 @@ +# @1shotapi/ows-signer-utils + +## 0.9.0 + +### Minor Changes + +- ec08739: Security remediations from the Signing Layer audit: require same-origin Branding↔Signing (parent origin gate, `createSignerIframe` check, CSP `frame-ancestors 'self'`), show requester origin on Confirm, make `clearSession()` clear the recovery session, and gate reveal/clipboard on document visibility and focus. Publish curated AI-assisted audit notes under `ows-signer/audits/2026-10-23-signing-layer/`. + +## 0.8.0 + +### Minor Changes + +- 972fd3b: Solana Phase 1: CAIP-2 `SolanaChainId` (`solana:mainnet` / `devnet` / `testnet`), branded program/mint/signature primitives, `SolanaSigner.signMessage` / `signTransaction` + Kit partial signer, and Host/Branding `solana.*` wire methods (`proxy.solana` / `wallet.solana.register`). + +### Patch Changes + +- Updated dependencies [972fd3b] + - @1shotapi/ows-types@0.14.0 + +## 0.7.0 + +### Minor Changes + +- 88a9f66: Remove RPC timeouts across Host and Signing layers. `RpcHostClient` (ows-provider) and `SignerRpcClient` (ows-signer-utils) no longer impose a 120s deadline — user-paced requests (`personal_sign`, `wallet_requestExecutionPermissions`, signer Confirm / WebAuthn, …) stay pending until the other layer responds, or reject on destroy / session clear. Removed: `OWSProxyOptions.rpcTimeoutMs`, `OWSSignerOptions.rpcTimeoutMs`, `RequestOptions.timeoutMs`, `DEFAULT_RPC_TIMEOUT_MS`, `OwsRpcTimeoutError`, and `OwsTimeoutError`. Hosts that need a bound on a non-interactive call should race their own deadline at the call site. + +### Patch Changes + +- Updated dependencies [88a9f66] + - @1shotapi/ows-types@0.13.0 + +## 0.6.7 + +### Patch Changes + +- Passkey ceremony UI uses a compact bottom sheet inside a transparent bottom-anchored host strip instead of a centered floating panel with outer shadow. + +## 0.6.6 + +### Patch Changes + +- Fix stale address cache after Change Account: always overwrite EVM/Solana/BTC caches from derived keys, and reject in-flight signer RPCs in `clearSession` so late `KeyDerived` events cannot refill the previous account. + +## 0.6.5 + +### Patch Changes + +- 6cd2357: Add `ChainUtils.asEVMChainId` (throws `OwsInvalidParamsError`) to normalize EIP-155 chain ids from number/string/bigint (with trim), and use it from signer/wallet EIP-1193 helpers instead of local `normalizeChainId` wrappers. Add `IEVMTransactionRequestSchema` and parse `eth_sendTransaction` params with Zod. +- Updated dependencies [6cd2357] + - @1shotapi/ows-types@0.12.0 + +## 0.6.3 + +### Patch Changes + +- 61ea18f: Fix flyout retention after personal_sign / typed-data (including SIWE): run optional `onAuthenticated` after display release, and harden `DisplayChildClient.releaseSession` so stale display IDs still decrement the live session instead of silently no-oping. +- 61ea18f: Add Zod 4 schemas on branded primitives (and `EChainTechnology`), with viem checksum / `@scure/base` address checks. Split AES-256 into `AES256CipherText` (raw hex) and `AES256CipherTextEnvelope` (`ows-aes1:…`). Wallet-utils EIP-1193 and credential composites now import the shared primitive schemas. +- Updated dependencies [61ea18f] + - @1shotapi/ows-types@0.11.0 + +## 0.6.2 + +### Patch Changes + +- 042beab: Bitcoin chain ids are now descriptive string sentinels (`"Bitcoin"`, `"BitcoinTestnet"`) instead of numeric `-1`/`-2`. Add `ChainUtils` for narrowing `OWSChainId` across EVM, Bitcoin, and Solana, plus `SolanaChainId` sentinels. +- Updated dependencies [042beab] + - @1shotapi/ows-types@0.8.0 + +## 0.6.0 + +### Minor Changes + +- 728eb8d: Add Bitcoin Native SegWit (P2WPKH) support: + - `ows-types`: Replace `BitcoinAccountAddress` with `BitcoinSegwitAccountAddress`. Add `BitcoinSignatureHex`, `BitcoinTransactionHash`, `BitcoinChainId` (-1 mainnet, -2 testnet), `OWSChainId`, BIP-122 static wallet types, and dual-network Bitcoin cache methods on `IOWSSigner`. + - `ows-signer-utils`: Add `BitcoinSigner` (`signer.bitcoin`), SegWit address derivation (`addressFromSecp256k1PublicKey`), BIP-143 transaction marshalling, and dual-network caching. + - `ows-provider`: Add `BitcoinHostClient` (`proxy.bitcoin.getAccountAddresses`) conforming to Reown BIP-122 static-wallet pattern. + - `ows-wallet-utils`: Add `validateBitcoinSegwitAddress` with strict bech32 SegWit v0 validation, and `BitcoinWalletRegistrar` (`wallet.bitcoin.register`). + +### Patch Changes + +- Updated dependencies [728eb8d] + - @1shotapi/ows-types@0.7.0 + +## 0.5.1 + +### Patch Changes + +- Clear stuck passkey ceremonies after timeout so a retry can start, and include RS256 alongside ES256 in WebAuthn create params. + + `ows-signer` now steals a hung ceremony lock, aborts in-flight `credentials.get/create`, and treats cancel/Abort as SignDenied. `ows-signer-utils` posts cancel before each new signer RPC so branding retries do not hit “already pending.” + +## 0.5.0 + +### Minor Changes + +- df9c4b7: Return full WebAuthn assertion fields (`authenticatorData`, `clientDataJSON`, `signature`, `credentialId`) from challenge-capable Signing Layer ceremonies as nested `assertion`, replacing `challengeSignature`. + +### Patch Changes + +- Updated dependencies [df9c4b7] + - @1shotapi/ows-types@0.5.0 + +## 0.4.0 + +### Minor Changes + +- 2f6bb67: Host-owned wallet sizing: flyout vs full-screen drawer, no branding size requests. + + `requestDisplay` is a show session only (no width/height). On small viewports the host panel opens as a full-screen drawer with a bottom wipe; larger viewports keep the lower-right flyout at `walletSizeX`/`walletSizeY`. + +### Patch Changes + +- Updated dependencies [2f6bb67] + - @1shotapi/ows-types@0.2.4 + +## 0.3.5 + +### Patch Changes + +- c8fd466: Add `createCredential` option `deferKeyDerivation` so Safari first-party registration can skip the PRF follow-up; `CredentialCreated.secp256k1PublicKey` is optional when deferred. +- Updated dependencies [c8fd466] + - @1shotapi/ows-types@0.2.3 + +## 0.3.4 + +### Patch Changes + +- eafc2c5: Add `OWSSigner.clearSession()` so branding can drop the in-memory credential id and address cache when switching accounts. + +## 0.3.3 + +### Patch Changes + +- 9d0836e: Branding owns personal_sign / typed-data consent **and** the Signing Layer ceremony (`approveAndSignPersonalMessage` / `approveAndSignTypedData`) so the consent view stays mounted under the signer panel. `SignHelper` is a thin EIP-1193 adapter only — `ensureReady` / `onAuthenticated` live in branding `approveAndSign*` wrappers, not on `SignHelperOptions`. + +## 0.3.2 + +### Patch Changes + +- 09d93bb: Add `importPrivateKey` — paste a hex secp256k1 key in the Signing Layer to start a recovery session. +- Updated dependencies [09d93bb] + - @1shotapi/ows-types@0.2.2 + +## 0.3.1 + +### Patch Changes + +- 08133a8: revealPrivateKey keeps the ceremony panel open until the user dismisses the key UI (PrivateKeyRevealed). +- Updated dependencies [08133a8] + - @1shotapi/ows-types@0.2.1 + +## 0.3.0 + +### Minor Changes + +- ae79e0f: Batch digest signing and `executeBatch` mixed ceremony so one passkey covers many signs/AES/auth ops; signing APIs are array-only (breaking). +- a39b7b2: Signing Layer passkey Confirm/Cancel UI (ceremony params + `SignDenied` / `OwsSignDeniedError`) and visible ceremony panel; remove invisible 1×1 WebAuthn prep and fold `credentialId` into options bags. + +### Patch Changes + +- Updated dependencies [ae79e0f] +- Updated dependencies [a39b7b2] + - @1shotapi/ows-types@0.2.0 + +## 0.2.2 + +### Patch Changes + +- 6856913: Always return `yParity` from `signedAuthorizationFromSignature`. After recoverable ECDSA started emitting `v` 27/28, the previous branch returned only `v`, so relayer auth payloads sent `yParity: null` ("Invalid authorization list entry"). + +## 0.2.1 + +### Patch Changes + +- ca64262: Fix recoverable ECDSA `v` for on-chain ecrecover (`27`/`28` instead of yParity `0`/`1`), which caused MetaMask Delegation / OpenZeppelin `ECDSAInvalidSignature()` on relayer estimate. Canonicalize message and typed-data signatures in `EvmSigner`. Prefer optional display `release()` over `hide()` in `SignHelper` so nested host RPC flyouts stay visible. Prefer cached address / public key in `toViemLocalAccount` to avoid an extra WebAuthn ceremony when warming a viem account. + +## 0.2.0 + +### Minor Changes + +- 498d823: Replace SignHelper `requestSendTransactionApproval` with branding-owned `approveAndSignTransaction`; export `prepareEvmTransaction`. Allow `camera` on the branding iframe for optional QR address scan. + +### Patch Changes + +- Updated dependencies [498d823] + - @1shotapi/ows-types@0.1.3 + +## 0.1.2 + +### Patch Changes + +- Add typed eth_sendTransaction support: branded transaction hash, RpcHelper active-chain request/broadcast helpers, and SignHelper prepare → sign → broadcast with transaction consent. +- Updated dependencies + - @1shotapi/ows-types@0.1.2 + +## 0.1.1 + +### Patch Changes + +- afc99f3: Replace PasskeyPublicKey with branded COSEPublicKey / SPKIPublicKey. Signing Layer emits COSE (`cosePublicKey`) from attestation authenticator data; add COSEToSPKIPublicKey in ows-signer-utils for Web Crypto consumers. +- Updated dependencies [afc99f3] + - @1shotapi/ows-types@0.1.1 + +## 0.1.0 + +### Minor Changes + +- 89e40a9: Phase 3 credentials hardening: `ICredentialRepository`, `IIssuerTrustRegistry.isTrustedIssuer`, `CredentialsHelper`, host `acceptedIssuers` on present, fail-closed status checks, and stubbed `OWSSigner.encryptAES256` / `decryptAES256` with branded `AES256CipherText`. +- b9831dd: Add `SignHelper` for headless EIP-1193 `personal_sign` / typed-data wiring (display → consent → ensureReady → sign). Branding apps supply consent UI and register the returned handlers on `OWSWallet`. +- 55501c4: Add `overlaySignerIframe` to visually place the Signing Layer iframe over a dialog slot without reparenting (avoids reload / dropped RPCs). Share inline-style capture/restore with `prepareSignerIframeForWebAuthn`. +- a803f5a: Implement OWSSigner SDK with RPC client, EVM signing namespace, and viem LocalAccount adapter. Emit RecoverySessionStarted from ows-signer after recoverKey success. Adopt `@1shotapi/ows-types` for shared signer protocol types and errors. + +### Patch Changes + +- 55f9a22: Implement PRF-derived `encryptAES256` / `decryptAES256` in the Signing Layer: AES-256-GCM keyed via HKDF from the wallet secp256k1 scalar (same material as `signDigest`), `ows-aes1:` envelopes, batch + recovery-session paths. +- 8eeecf2: Add branded `HexString`, `Base64UrlEncodedString`, and `SdJwtVcPresentationString` primitives. Add branded public key primitives (`PasskeyPublicKey`, `SECP256K1PublicKey`, `ED25519PublicKey`) and apply them to signer protocol types and RPC parsing in `ows-signer-utils`. +- Updated dependencies [0e1b1d1] +- Updated dependencies [aaf43d4] +- Updated dependencies [e83e5ce] +- Updated dependencies [3c8b506] +- Updated dependencies [89e40a9] +- Updated dependencies [a78bba8] +- Updated dependencies [9a87faf] +- Updated dependencies [eae834a] +- Updated dependencies [55f9a22] +- Updated dependencies [aaf43d4] +- Updated dependencies [8eeecf2] + - @1shotapi/ows-types@0.1.0 diff --git a/packages/ows-signer-utils/README.md b/packages/ows-signer-utils/README.md new file mode 100644 index 0000000..9000635 --- /dev/null +++ b/packages/ows-signer-utils/README.md @@ -0,0 +1,180 @@ +# @1shotapi/ows-signer-utils + +Typed, browser-only SDK for **Branding Layer** authors: embed the OWS Signing Layer iframe, speak the OWS v1 `postMessage` protocol, and expose `OWSSigner` with passkey/recovery helpers plus a Viem-aligned `signer.evm` namespace. + +## Install + +```bash +npm install @1shotapi/ows-signer-utils viem +``` + +`viem` is a **peer dependency** (^2.x). This package uses viem for EIP-191/712/transaction/7702 hashing, serialization, and types — not as a wallet client. + +## Quick start + +```typescript +import { OWSSigner, toViemLocalAccount } from "@1shotapi/ows-signer-utils"; + +const container = document.getElementById("signer-slot")!; +// Signer must be same-origin as this Branding page (e.g. /signer/ on this host). +const signer = await OWSSigner.create( + container, + new URL("/signer/", window.location.origin).href, + { credentialId: optionalExistingId }, +); + +await signer.createCredential("my-wallet", { rpName: "MyBrand" }); + +const address = await signer.evm.getAccountAddress(); +const [sig] = await signer.evm.signMessage(["hello"]); +const [typedSig] = await signer.evm.signTypedData([ + { + domain: { name: "App", version: "1", chainId: 1, verifyingContract: "0x…" }, + types: { … }, + primaryType: "Mail", + message: { … }, + }, +]); + +// Optional: viem LocalAccount for smart-account kits +const account = await toViemLocalAccount(signer); +``` + +## Architecture + +``` +Host Layer → Branding Layer → Signing Layer + ↑ + @1shotapi/ows-signer-utils +``` + +This package runs in the **Branding Layer**. It must not be used from the host page directly — the Signing Layer requires the branding iframe for correct WebAuthn / iframe nesting. + +Outbound RPC: `iframe.contentWindow.postMessage(request, signerOrigin)` +Inbound events: validate `event.origin === signerOrigin` and `event.source === iframe.contentWindow`. + +## Iframe requirements + +- Parent page must be a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) (HTTPS or localhost). +- The iframe is created with `allow="publickey-credentials-get *"` for passkey ceremonies. +- Do **not** add a restrictive `sandbox` that blocks WebAuthn. +- Default iframe size is hidden (`0×0`); pass `{ hidden: false }` to show it. + +### Signer display (passkey Confirm / passphrase) + +`OWSSigner` automatically shows a bottom full-width ceremony strip (`showSignerCeremonyPanel`) for WebAuthn Confirm UI and recovery passphrase/reveal. The strip is **half the branding viewport height** (`50dvh`). Callers do **not** need a 1×1 invisible focus layer. + +| Helper | Purpose | +|--------|---------| +| `showSignerCeremonyPanel(iframe)` | Bottom-anchored transparent host for the signer iframe (used internally by `OWSSigner`). Returns a restore function. | +| `OWSSignerOptions.onCeremonyPanel` | Optional `(open: boolean)` when the ceremony host strip opens/closes (e.g. branding scrim). | +| `overlaySignerIframe(iframe, slot, options?)` | Optional slot-aligned overlay if you still need to pin the iframe over a specific element (prefer the auto panel for new code). | + +Pass optional `explanationHeader` / `explanationText` / `confirmButtonText` / `denyButtonText` on ceremony RPCs. Cancel emits `SignDenied` → `OwsSignDeniedError`. + +### EIP-1193 signing (`SignHelper`) + +Thin EIP-1193 adapter: parse params, request display, call branding `approveAndSign*` handlers. Does **not** talk to `OWSSigner` for ceremonies — branding owns consent, setup/unlock, and Signing Layer calls. Does not register handlers — the branding app owns order: + +```typescript +import { SignHelper, prepareEvmTransaction } from "@1shotapi/ows-signer-utils"; + +const signHelper = new SignHelper(signer, wallet, { + getChainId: () => rpcHelper.getChainId(), + // Branding: ensureReady → consent UI + OWSSigner → onAuthenticated → value + approveAndSignPersonalMessage, + approveAndSignTypedData, + approveAndSignTransaction, +}); +for (const [method, handler] of Object.entries(signHelper.handlers)) { + wallet.registerEip1193(method, handler); +} +``` + +Keep branding consent views mounted until the signer ceremony finishes. Export `prepareEvmTransaction(chainRpc, account, tx)` for branding / relayer submit paths. + +## API + +### `OWSSigner.create(container, signerUrl, options?)` + +| Option | Description | +|--------|-------------| +| `credentialId` | Existing WebAuthn credential id | +| `hidden` | Hide iframe (default `true`) | +### Root methods (Signing Layer passthrough) + +| Method | Description | +|--------|-------------| +| `getVersion()` | Signer API/version info | +| `createCredential(name, options?)` | Register passkey; options include `rpName`, ceremony UI fields | +| `signDigest(digests[], options?)` | Sign digests under one ceremony (`credentialId` + ceremony UI) | +| `executeBatch(params)` | Mixed ceremony: digests + AES + public key + optional WebAuthn `challenge` | +| `getPublicKey(params?)` | Derive keys; optional `challenge` / ceremony UI | +| `createRecoveryData(..., options?)` | Encrypt recovery blob (ceremony UI + `credentialId`) | +| `recoverKey(..., options?)` | Decrypt recovery blob into session | +| `revealPrivateKey(options?)` | Signer UI to reveal key (stays open until Done) | +| `importPrivateKey()` | Signer UI to paste hex key → recovery session | +| `clearSession()` | Drop credential/address cache **and** end recovery session | +| `clearRecoverySession()` | End recovery session only | +| `encryptAES256(plaintexts, options?)` | Batch AES-256-GCM seal (`ows-aes1:`) | +| `decryptAES256(ciphertexts, options?)` | Batch AES-256-GCM unseal | + +### `signer.evm` + +| Method | Description | +|--------|-------------| +| `getAccountAddress()` | Ethereum address from secp256k1 public key | +| `signMessage(messages[], options?)` | EIP-191 personal sign (batch) | +| `signTypedData(typedDataList[], options?)` | EIP-712 (batch) | +| `signTransaction(transactions[], options?)` | Signed serialized tx hex (batch) | +| `signAuthorization(authorizations[], options?)` | EIP-7702 authorization (batch) | + +All EVM batch methods accept optional `{ credentialId, explanationHeader, … }` once for the whole batch. + +### `signer.solana` + +| Method | Description | +|--------|-------------| +| `getAccountAddress()` | Solana address (base58 ed25519 public key) | +| `signMessage(messages[], options?)` | Ed25519 over raw message bytes (batch) → base58 `SolanaSignature` | +| `signTransaction(txs[], options?)` | Ed25519 over compiled tx **message** bytes (batch) | + +Host / Wallet Standard mapping (Branding implements consent + wire handlers later): + +| Surface | Wire / signer | +|---------|----------------| +| `solana:signMessage` | `solana.signMessage` → `signMessage` | +| `solana:signIn` (SIWS) | Branding formats message → `signMessage` | +| `solana:signTransaction` / sign-and-send | `signTransaction` then Branding submits | + +`toKitPartialSigner(signer)` builds a Kit `MessagePartialSigner` + `TransactionPartialSigner` that delegates to these methods (peer `@solana/kit`). + +Accepts optional `{ credentialId, explanationHeader, … }` per ceremony. + +### `signer.bitcoin` + +| Method | Description | +|--------|-------------| +| `getAccountAddress(chainId?)` | Native SegWit address (P2WPKH: `bc1q…` on Mainnet -1, `tb1q…` on Testnet -2) | +| `signTransaction(transactions[], options?)` | Sign and finalize P2WPKH transactions via `signDigest` | + +Accepts optional `{ credentialId, explanationHeader, … }` per ceremony. + +### Errors + +- `OwsNotAllowedError` — WebAuthn cancelled or policy blocked +- `OwsSignDeniedError` — user cancelled Signing Layer Confirm UI (before WebAuthn) +- `OwsInvalidRequestError` — bad params + +Signer RPC has no client-imposed timeout: requests stay pending through the Confirm UI / WebAuthn until the Signing Layer replies, or reject on session clear / destroy. +## Related packages + +| Package | Role | +|---------|------| +| `@1shotapi/ows-signer` | Plain JS Signing Layer | +| `@1shotapi/ows-wallet-utils` | Host Layer ↔ Branding Layer messaging | +| `@1shotapi/ows-provider` | EIP-1193 provider for Host Layer apps | + +## License + +MIT diff --git a/packages/ows-signer-utils/package.json b/packages/ows-signer-utils/package.json new file mode 100644 index 0000000..85edf5f --- /dev/null +++ b/packages/ows-signer-utils/package.json @@ -0,0 +1,58 @@ +{ + "name": "@1shotapi/ows-signer-utils", + "version": "0.9.0", + "description": "OWS utilities to embed the Signing Layer iframe and communicate via postMessage — chain-specific signing helpers (EVM, etc.)", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "node --import tsx --test test/*.test.ts", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@1shotapi/ows-types": "*", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@scure/base": "^1.2.5", + "@scure/btc-signer": "^2.4.1" + }, + "peerDependencies": { + "@solana/kit": "^8.5.0", + "viem": "^2.0.0" + }, + "devDependencies": { + "@solana/kit": "^8.5.0", + "tsx": "^4.19.0", + "viem": "^2.52.0" + }, + "keywords": [ + "ows", + "open-wallet-standard", + "webauthn", + "prf", + "signer", + "evm", + "iframe" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-signer-utils" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ows-signer-utils/src/bitcoin/address.ts b/packages/ows-signer-utils/src/bitcoin/address.ts new file mode 100644 index 0000000..93e5d37 --- /dev/null +++ b/packages/ows-signer-utils/src/bitcoin/address.ts @@ -0,0 +1,55 @@ +import { + BITCOIN_MAINNET_CHAIN_ID, + ConversionUtils, + HexString, + type BitcoinChainId, + BitcoinSegwitAccountAddress, + type SECP256K1PublicKey, +} from "@1shotapi/ows-types"; +import * as btc from "@scure/btc-signer"; + +export function compressSecp256k1PublicKey( + uncompressed: SECP256K1PublicKey | Uint8Array, +): Uint8Array { + const bytes = + uncompressed instanceof Uint8Array + ? uncompressed + : ConversionUtils.hexToBytes(HexString(uncompressed)); + + if (bytes.length === 33) { + return bytes; + } + + if (bytes.length !== 65 || bytes[0] !== 4) { + throw new Error( + `Invalid uncompressed secp256k1 public key: expected 65 bytes starting with 0x04, got length ${bytes.length}`, + ); + } + + const compressed = new Uint8Array(33); + compressed[0] = (bytes[64]! & 1) === 0 ? 2 : 3; + compressed.set(bytes.subarray(1, 33), 1); + return compressed; +} + +export function bitcoinNetworkForChainId(chainId: BitcoinChainId) { + return chainId === BITCOIN_MAINNET_CHAIN_ID + ? btc.NETWORK + : btc.TEST_NETWORK; +} + +/** + * Derive a Native SegWit (P2WPKH, bc1q/tb1q) address from a secp256k1 public key. + */ +export function addressFromSecp256k1PublicKey( + publicKey: SECP256K1PublicKey | Uint8Array, + chainId: BitcoinChainId = BITCOIN_MAINNET_CHAIN_ID, +): BitcoinSegwitAccountAddress { + const compressed = compressSecp256k1PublicKey(publicKey); + const network = bitcoinNetworkForChainId(chainId); + const p2w = btc.p2wpkh(compressed, network); + if (!p2w.address) { + throw new Error("Failed to derive P2WPKH address"); + } + return BitcoinSegwitAccountAddress(p2w.address); +} diff --git a/packages/ows-signer-utils/src/bitcoin/marshal.ts b/packages/ows-signer-utils/src/bitcoin/marshal.ts new file mode 100644 index 0000000..886588b --- /dev/null +++ b/packages/ows-signer-utils/src/bitcoin/marshal.ts @@ -0,0 +1,161 @@ +import { + BITCOIN_MAINNET_CHAIN_ID, + ConversionUtils, + HexString, + type BitcoinChainId, + BitcoinSignatureHex, + type BitcoinSegwitAccountAddress, + BitcoinTransactionHash, + type SECP256K1PublicKey, +} from "@1shotapi/ows-types"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import * as btc from "@scure/btc-signer"; +import { + bitcoinNetworkForChainId, + compressSecp256k1PublicKey, +} from "./address.js"; + +export interface IBitcoinTransactionInput { + txid: BitcoinTransactionHash | string; + vout: number; + valueSats: bigint; +} + +export interface IBitcoinTransactionOutput { + address: BitcoinSegwitAccountAddress | string; + valueSats: bigint; +} + +export interface IBitcoinUnsignedTransaction { + chainId?: BitcoinChainId; + inputs: IBitcoinTransactionInput[]; + outputs: IBitcoinTransactionOutput[]; + lockTime?: number; +} + +export interface IBitcoinSignedTransactionResult { + rawHex: string; + txid: BitcoinTransactionHash; + signatures: BitcoinSignatureHex[]; +} + +export interface IPreparedBitcoinTransaction { + tx: btc.Transaction; + sighashes: Uint8Array[]; + compressedPubKey: Uint8Array; +} + +/** + * Build a SegWit P2WPKH transaction and compute its BIP-143 sighashes for signing. + */ +export function prepareBitcoinTransaction( + unsignedTx: IBitcoinUnsignedTransaction, + publicKey: SECP256K1PublicKey | Uint8Array, +): IPreparedBitcoinTransaction { + if (unsignedTx.inputs.length === 0) { + throw new Error("prepareBitcoinTransaction: transaction must have at least one input"); + } + if (unsignedTx.outputs.length === 0) { + throw new Error("prepareBitcoinTransaction: transaction must have at least one output"); + } + + const chainId = unsignedTx.chainId ?? BITCOIN_MAINNET_CHAIN_ID; + const network = bitcoinNetworkForChainId(chainId); + const compressedPubKey = compressSecp256k1PublicKey(publicKey); + const p2w = btc.p2wpkh(compressedPubKey, network); + + const tx = new btc.Transaction({ + allowUnknownOutputs: false, + disableScriptCheck: false, + lockTime: unsignedTx.lockTime, + }); + + for (const input of unsignedTx.inputs) { + const cleanTxid = input.txid.startsWith("0x") + ? input.txid.slice(2).toLowerCase() + : input.txid.toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(cleanTxid)) { + throw new Error(`Invalid input txid: ${input.txid}`); + } + + tx.addInput({ + txid: cleanTxid, + index: input.vout, + witnessUtxo: { + script: p2w.script, + amount: input.valueSats, + }, + }); + } + + for (const output of unsignedTx.outputs) { + tx.addOutputAddress(output.address, output.valueSats, network); + } + + const script = btc.OutScript.encode({ type: "pkh", hash: p2w.hash }); + const sighashes: Uint8Array[] = []; + + for (let i = 0; i < unsignedTx.inputs.length; i++) { + const input = unsignedTx.inputs[i]!; + const sighash = tx.preimageWitnessV0( + i, + script, + btc.SigHash.ALL, + input.valueSats, + ); + sighashes.push(sighash); + } + + return { tx, sighashes, compressedPubKey }; +} + +/** + * Apply compact ECDSA signatures (from `signDigest` scheme `secp256k1-ecdsa`), + * attach witness scripts, and finalize the transaction. + */ +export function finalizeBitcoinTransaction( + prepared: IPreparedBitcoinTransaction, + compactSignaturesHex: Array<`0x${string}` | HexString>, +): IBitcoinSignedTransactionResult { + const { tx, compressedPubKey } = prepared; + + if (compactSignaturesHex.length !== tx.inputsLength) { + throw new Error( + `Signature count (${compactSignaturesHex.length}) does not match input count (${tx.inputsLength})`, + ); + } + + const signatures: BitcoinSignatureHex[] = []; + + for (let i = 0; i < compactSignaturesHex.length; i++) { + const sigHex = compactSignaturesHex[i]!; + const compactBytes = ConversionUtils.hexToBytes(HexString(sigHex)); + if (compactBytes.length !== 64) { + throw new Error( + `Invalid compact signature length for input ${i}: expected 64 bytes, got ${compactBytes.length}`, + ); + } + + const parsedSig = secp256k1.Signature.fromBytes(compactBytes, "compact"); + const derBytes = parsedSig.toBytes("der"); + const sigWithHash = new Uint8Array(derBytes.length + 1); + sigWithHash.set(derBytes); + sigWithHash[derBytes.length] = btc.SigHash.ALL; + + tx.updateInput(i, { + partialSig: [[compressedPubKey, sigWithHash]], + }); + + signatures.push( + BitcoinSignatureHex(ConversionUtils.bytesToHex(sigWithHash)), + ); + } + + tx.finalize(); + + return { + rawHex: tx.hex, + txid: BitcoinTransactionHash(tx.id), + signatures, + }; +} diff --git a/packages/ows-signer-utils/src/bitcoin/namespace.ts b/packages/ows-signer-utils/src/bitcoin/namespace.ts new file mode 100644 index 0000000..295d09b --- /dev/null +++ b/packages/ows-signer-utils/src/bitcoin/namespace.ts @@ -0,0 +1,110 @@ +import { + BITCOIN_MAINNET_CHAIN_ID, + ConversionUtils, + type BitcoinChainId, + type BitcoinSegwitAccountAddress, + type CeremonyUiParams, + type CredentialId, + type SECP256K1PublicKey, +} from "@1shotapi/ows-types"; +import type { OWSSigner } from "../owssigner.js"; +import { addressFromSecp256k1PublicKey } from "./address.js"; +import { + finalizeBitcoinTransaction, + prepareBitcoinTransaction, + type IBitcoinSignedTransactionResult, + type IBitcoinUnsignedTransaction, +} from "./marshal.js"; + +export type BitcoinCallOptions = CeremonyUiParams & { + credentialId?: CredentialId; +}; + +export class BitcoinSigner { + constructor(private readonly signer: OWSSigner) {} + + /** + * Return the P2WPKH address for the given chain ID (`Bitcoin` / `BitcoinTestnet`). + */ + async getAccountAddress( + chainId: BitcoinChainId = BITCOIN_MAINNET_CHAIN_ID, + options?: BitcoinCallOptions, + ): Promise { + const cached = this.signer.getCachedBitcoinSegwitAddress(chainId); + if (cached) return cached; + + const publicKeyData = await this.signer.getPublicKey({ + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }); + + const address = addressFromSecp256k1PublicKey( + publicKeyData.secp256k1PublicKey, + chainId, + ); + this.signer.setCachedBitcoinSegwitAddress(chainId, address); + return address; + } + + /** + * Sign one or more Bitcoin P2WPKH transactions in a single ceremony. + */ + async signTransaction( + transactions: IBitcoinUnsignedTransaction[], + options?: BitcoinCallOptions, + ): Promise { + if (transactions.length === 0) return []; + + let publicKey: SECP256K1PublicKey | undefined = + this.signer.getLastPublicKeyData()?.secp256k1PublicKey; + + if (!publicKey) { + const pubKeyData = await this.signer.getPublicKey({ + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }); + publicKey = pubKeyData.secp256k1PublicKey; + } + + const preparedList = transactions.map((tx) => + prepareBitcoinTransaction(tx, publicKey!), + ); + + const allDigests = preparedList.flatMap((p) => + p.sighashes.map((sh) => ({ + digestData: ConversionUtils.bytesToHex(sh), + scheme: "secp256k1-ecdsa" as const, + })), + ); + + const results = await this.signer.signDigest(allDigests, { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }); + + let offset = 0; + const signedResults: IBitcoinSignedTransactionResult[] = []; + + for (const prepared of preparedList) { + const count = prepared.sighashes.length; + const txSignatures = results + .slice(offset, offset + count) + .map((r) => r.signature); + offset += count; + + const finalized = finalizeBitcoinTransaction(prepared, txSignatures); + signedResults.push(finalized); + } + + return signedResults; + } +} diff --git a/packages/ows-signer-utils/src/eip1193/approval-types.ts b/packages/ows-signer-utils/src/eip1193/approval-types.ts new file mode 100644 index 0000000..621a0e7 --- /dev/null +++ b/packages/ows-signer-utils/src/eip1193/approval-types.ts @@ -0,0 +1,40 @@ +import type { + EVMAccountAddress, + EVMChainId, + HexString, + IEVMTransactionRequest, +} from "@1shotapi/ows-types"; + +/** EIP-191 personal_sign consent payload for branding UI. */ +export type PersonalSignApprovalRequest = { + message: string; + address: EVMAccountAddress; +}; + +/** EIP-712 typed data payload (`eth_signTypedData` / `_v3` / `_v4`). */ +export type SignTypedDataPayload = { + types: Record>; + primaryType: string; + domain: Record; + message: Record; +}; + +/** EIP-712 typed-data consent payload for branding UI. */ +export type SignTypedDataApprovalRequest = { + address: EVMAccountAddress; + typedData: SignTypedDataPayload; +}; + +/** + * `eth_sendTransaction` payload for branding `approveAndSignTransaction`. + * Branding owns consent + prepare + sign + broadcast; return the tx hash. + */ +export type SendTransactionApprovalRequest = { + address: EVMAccountAddress; + to: EVMAccountAddress | null; + data: HexString; + value: HexString; + chainId: EVMChainId; + /** Normalized tx ready for `prepareEvmTransaction`. */ + transaction: IEVMTransactionRequest; +}; diff --git a/packages/ows-signer-utils/src/eip1193/sign-helper.ts b/packages/ows-signer-utils/src/eip1193/sign-helper.ts new file mode 100644 index 0000000..c6154bb --- /dev/null +++ b/packages/ows-signer-utils/src/eip1193/sign-helper.ts @@ -0,0 +1,339 @@ +import { + CredentialId, + EVMAccountAddress, + EVMSignatureHex, + EVMTransactionHash, + HexString, + IEVMTransactionRequestSchema, + OwsInvalidParamsError, + type EVMChainId, + type IEVMTransactionRequest, + type RequestDisplayParams, +} from "@1shotapi/ows-types"; +import { + createPublicClient, + custom, + defineChain, + type Hex, + type TransactionSerializable, + type TypedDataDefinition, +} from "viem"; +import { prepareTransactionRequest } from "viem/actions"; +import type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, + SignTypedDataPayload, +} from "./approval-types.js"; + +/** EIP-1193 handler shape (matches `OWSWallet.registerEip1193`). */ +export type Eip1193Handler = (params: unknown[]) => Promise; + +export type SignHelperDisplaySession = { + hide(): Promise; + /** Preferred end-of-session cleanup; keeps host RPC flyout alive when nested. */ + release?(): void; +}; + +/** Wallet surface used to show the branding iframe during consent / signing. */ +export type SignHelperWallet = { + requestDisplay( + params?: RequestDisplayParams, + ): Promise; +}; + +/** Minimal EVM signing surface (`OWSSigner` / `OWSSigner.evm` subset). */ +export type SignHelperSigner = { + /** Prefer cache so signing flows avoid an extra getPublicKey ceremony. */ + getCachedAddress?: () => EVMAccountAddress | null | undefined; + evm: { + getAccountAddress(options?: { + credentialId?: CredentialId; + }): Promise; + signMessage( + messages: string[], + options?: { credentialId?: CredentialId }, + ): Promise; + signTypedData( + typedDataList: TypedDataDefinition[], + options?: { credentialId?: CredentialId }, + ): Promise; + signTransaction( + transactions: TransactionSerializable[], + options?: { credentialId?: CredentialId }, + ): Promise; + }; +}; + +/** + * Active-chain JSON-RPC surface for prepare + broadcast. + * Typically an `RpcHelper` from `ows-wallet-utils` (no package import here). + */ +export type SignHelperChainRpc = { + getChainId(): EVMChainId; + request(method: string, params?: unknown[]): Promise; +}; + +export type SignHelperOptions = { + /** + * Branding owns consent UI + Signing Layer `signMessage` for EIP-191 + * `personal_sign` (including setup/auth side effects). Keep the consent + * view mounted until the ceremony finishes. + */ + approveAndSignPersonalMessage: ( + request: PersonalSignApprovalRequest, + ) => Promise; + /** + * Branding owns consent UI + Signing Layer `signTypedData` for EIP-712 + * (including setup/auth side effects). Keep the consent view mounted until + * the ceremony finishes. + */ + approveAndSignTypedData: ( + request: SignTypedDataApprovalRequest, + ) => Promise; + /** + * Branding owns consent + prepare + passkey sign + broadcast for + * `eth_sendTransaction` (including setup/auth side effects). Return the + * mined/submitted tx hash. Keep the consent view mounted until the + * ceremony finishes. + */ + approveAndSignTransaction: ( + request: SendTransactionApprovalRequest, + ) => Promise; + /** Active EIP-1193 chain id (for request validation). */ + getChainId: () => EVMChainId; + /** + * After a successful personal_sign / typed-data / eth_sendTransaction ceremony, + * once the display session has been released. Branding uses this for unlock + + * address refresh so post-sign work cannot hold the flyout open. + */ + onAuthenticated?: () => void | Promise; +}; + +export type Eip1193SignHandlers = { + personal_sign: Eip1193Handler; + eth_signTypedData: Eip1193Handler; + eth_signTypedData_v3: Eip1193Handler; + eth_signTypedData_v4: Eip1193Handler; + eth_sendTransaction: Eip1193Handler; +}; + +const ZERO_VALUE = HexString("0x0"); +const EMPTY_DATA = HexString("0x"); + +/** + * Thin EIP-1193 adapter: parse params, request display, call branding + * `approveAndSign*` handlers. Does **not** register handlers — the branding + * app calls `wallet.registerEip1193` in the order it wants. + * + * Setup (`ensureReady`) belongs inside branding's approve callbacks. + * Post-ceremony unlock (`onAuthenticated`) runs after display release. + */ +export class SignHelper { + readonly handlers: Eip1193SignHandlers; + + constructor( + private readonly signer: SignHelperSigner, + private readonly wallet: SignHelperWallet, + private readonly options: SignHelperOptions, + ) { + const typedData: Eip1193Handler = (params) => this.handleTypedData(params); + this.handlers = { + personal_sign: (params) => this.handlePersonalSign(params), + eth_signTypedData: typedData, + eth_signTypedData_v3: typedData, + eth_signTypedData_v4: typedData, + eth_sendTransaction: (params) => this.handleSendTransaction(params), + }; + } + + private async handlePersonalSign(params: unknown[]): Promise { + const [message, addressParam] = params as [string, string]; + const address = EVMAccountAddress(addressParam as `0x${string}`); + + const signature = await this.withDisplay(() => + this.options.approveAndSignPersonalMessage({ + message, + address, + }), + ); + await this.options.onAuthenticated?.(); + return signature; + } + + private async handleTypedData(params: unknown[]): Promise { + const [addressParam, typedDataParam] = params as [ + string, + SignTypedDataPayload | string, + ]; + const address = EVMAccountAddress(addressParam as `0x${string}`); + const typedData = parseTypedData(typedDataParam); + + const signature = await this.withDisplay(() => + this.options.approveAndSignTypedData({ + address, + typedData, + }), + ); + await this.options.onAuthenticated?.(); + return signature; + } + + private async handleSendTransaction( + params: unknown[], + ): Promise { + const tx = parseTransactionRequest(params[0]); + const chainId = this.options.getChainId(); + if (tx.chainId !== undefined && tx.chainId !== chainId) { + throw new OwsInvalidParamsError( + `eth_sendTransaction chainId ${tx.chainId} does not match active chain ${chainId}`, + ); + } + + const hash = await this.withDisplay(async () => { + const address = await this.resolveAccount(tx.from); + if (tx.from && !sameAddress(tx.from, address)) { + throw new OwsInvalidParamsError( + "eth_sendTransaction from does not match the active account", + ); + } + + const to = tx.to === undefined || tx.to === null ? null : tx.to; + const data = tx.data ?? EMPTY_DATA; + const value = tx.value ?? ZERO_VALUE; + const transaction: IEVMTransactionRequest = { + ...tx, + from: address, + chainId, + }; + + const result = await this.options.approveAndSignTransaction({ + address, + to, + data, + value, + chainId, + transaction, + }); + if (typeof result !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(result)) { + throw new OwsInvalidParamsError( + "approveAndSignTransaction returned an invalid transaction hash", + ); + } + return EVMTransactionHash(result as `0x${string}`); + }); + await this.options.onAuthenticated?.(); + return hash; + } + + private async resolveAccount( + from?: EVMAccountAddress, + ): Promise { + const cached = this.signer.getCachedAddress?.() ?? null; + if (from) { + if (cached && !sameAddress(from, cached)) { + throw new OwsInvalidParamsError( + "eth_sendTransaction from does not match the active account", + ); + } + return from; + } + if (cached) { + return cached; + } + return this.signer.evm.getAccountAddress(); + } + + private async withDisplay(run: () => Promise): Promise { + const display = await this.wallet.requestDisplay({}); + try { + return await run(); + } finally { + // Prefer release over hide so an in-flight host RPC (rpcAccessCount > 0) + // can keep the flyout visible until eth_sendTransaction fully settles. + if ("release" in display && typeof display.release === "function") { + display.release(); + } else { + await display.hide(); + } + } + } +} + +export function parseTypedData( + value: SignTypedDataPayload | string, +): SignTypedDataPayload { + if (typeof value === "string") { + return JSON.parse(value) as SignTypedDataPayload; + } + return value; +} + +function parseTransactionRequest(value: unknown): IEVMTransactionRequest { + const parsed = IEVMTransactionRequestSchema.safeParse(value); + if (!parsed.success) { + const detail = parsed.error.issues[0]?.message ?? "invalid transaction"; + throw new OwsInvalidParamsError( + `eth_sendTransaction requires a valid transaction object (${detail})`, + ); + } + return parsed.data; +} + +function sameAddress(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function hexQuantityToBigInt(value: string | undefined): bigint | undefined { + if (value === undefined) return undefined; + return BigInt(value); +} + +export async function prepareEvmTransaction( + chainRpc: SignHelperChainRpc, + account: EVMAccountAddress, + tx: IEVMTransactionRequest, +): Promise { + const chainId = chainRpc.getChainId(); + const chain = defineChain({ + id: Number(BigInt(chainId)), + name: "ows-active", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["http://127.0.0.1"] } }, + }); + + const client = createPublicClient({ + chain, + transport: custom({ + async request({ method, params }) { + return chainRpc.request(method, (params as unknown[]) ?? []); + }, + }), + }); + + const base = { + account, + chain, + to: (tx.to ?? undefined) as Hex | undefined, + data: (tx.data as Hex | undefined) ?? undefined, + value: hexQuantityToBigInt(tx.value), + gas: hexQuantityToBigInt(tx.gas), + nonce: + tx.nonce !== undefined ? Number(hexQuantityToBigInt(tx.nonce)) : undefined, + }; + + const prepared = + tx.gasPrice !== undefined + ? await prepareTransactionRequest(client, { + ...base, + gasPrice: hexQuantityToBigInt(tx.gasPrice), + type: "legacy", + }) + : await prepareTransactionRequest(client, { + ...base, + maxFeePerGas: hexQuantityToBigInt(tx.maxFeePerGas), + maxPriorityFeePerGas: hexQuantityToBigInt(tx.maxPriorityFeePerGas), + }); + + return prepared as TransactionSerializable; +} diff --git a/packages/ows-signer-utils/src/errors.ts b/packages/ows-signer-utils/src/errors.ts new file mode 100644 index 0000000..7077ba0 --- /dev/null +++ b/packages/ows-signer-utils/src/errors.ts @@ -0,0 +1,6 @@ +export { + OwsSignerError, + OwsInvalidRequestError, + OwsNotAllowedError, + OwsSignDeniedError, +} from "@1shotapi/ows-types"; diff --git a/packages/ows-signer-utils/src/evm/marshal.ts b/packages/ows-signer-utils/src/evm/marshal.ts new file mode 100644 index 0000000..d0bb94d --- /dev/null +++ b/packages/ows-signer-utils/src/evm/marshal.ts @@ -0,0 +1,75 @@ +import { + hashMessage, + hashTypedData, + keccak256, + parseSignature, + serializeTransaction, +} from "viem"; +import { hashAuthorization } from "viem/utils"; +import type { + AuthorizationRequest, + Hex, + SignableMessage, + SignedAuthorization, + TransactionSerializable, + TypedDataDefinition, +} from "viem"; + +export function digestForMessage(message: SignableMessage): Hex { + return hashMessage(message); +} + +export function digestForTypedData( + typedData: TypedDataDefinition, +): Hex { + return hashTypedData(typedData); +} + +export function digestForTransaction( + transaction: TransactionSerializable, +): Hex { + const serialized = serializeTransaction(transaction); + return keccak256(serialized); +} + +export function digestForAuthorization( + authorization: AuthorizationRequest, +): Hex { + return hashAuthorization(authorization); +} + +export function serializeSignedTransaction( + transaction: TransactionSerializable, + signature: Hex, +): Hex { + return serializeTransaction(transaction, parseSignature(signature)); +} + +export function signedAuthorizationFromSignature( + authorization: AuthorizationRequest, + signature: Hex, +): SignedAuthorization { + const parsed = parseSignature(signature); + const address = authorization.address ?? authorization.contractAddress; + if (!address) { + throw new Error("Authorization requires address or contractAddress"); + } + + // Relayers and EIP-7702 JSON payloads expect `yParity` (0|1). Prefer that even when + // parseSignature also returns `v` (27|28) — omitting yParity becomes JSON null. + const yParity = + parsed.yParity ?? + (parsed.v !== undefined ? (Number(parsed.v) % 2 === 0 ? 1 : 0) : undefined); + if (yParity !== 0 && yParity !== 1) { + throw new Error("Authorization signature missing yParity"); + } + + return { + address, + chainId: authorization.chainId, + nonce: authorization.nonce, + r: parsed.r, + s: parsed.s, + yParity, + }; +} diff --git a/packages/ows-signer-utils/src/evm/namespace.ts b/packages/ows-signer-utils/src/evm/namespace.ts new file mode 100644 index 0000000..efd8e16 --- /dev/null +++ b/packages/ows-signer-utils/src/evm/namespace.ts @@ -0,0 +1,150 @@ +import { + CredentialId, + EVMAccountAddress, + EVMSignatureHex, + HexString, + type CeremonyUiParams, +} from "@1shotapi/ows-types"; +import type { + AuthorizationRequest, + Hex, + SignableMessage, + SignedAuthorization, + TransactionSerializable, + TypedDataDefinition, +} from "viem"; +import { parseSignature, serializeSignature } from "viem"; +import { publicKeyToAddress } from "viem/utils"; +import type { OWSSigner } from "../owssigner.js"; +import { + digestForAuthorization, + digestForMessage, + digestForTransaction, + digestForTypedData, + serializeSignedTransaction, + signedAuthorizationFromSignature, +} from "./marshal.js"; + +const EVM_SIGN_SCHEME = "secp256k1-ecdsa-recoverable" as const; + +/** Canonical 65-byte secp256k1 sig with v ∈ {27, 28} for on-chain ecrecover. */ +function toEvmRecoverableSignature(signature: Hex): EVMSignatureHex { + return EVMSignatureHex(serializeSignature(parseSignature(signature))); +} + +export type EvmCallOptions = CeremonyUiParams & { + credentialId?: CredentialId; +}; + +export class EvmSigner { + constructor(private readonly signer: OWSSigner) {} + + async getAccountAddress( + options?: EvmCallOptions, + ): Promise { + const cached = this.signer.getCachedAddress(); + if (cached) return cached; + + const publicKey = await this.signer.getPublicKey({ + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }); + + const address = EVMAccountAddress( + publicKeyToAddress(publicKey.secp256k1PublicKey), + ); + this.signer.setCachedAddress(address); + return address; + } + + async signMessage( + messages: SignableMessage[], + options?: EvmCallOptions, + ): Promise { + if (messages.length === 0) return []; + const results = await this.signer.signDigest( + messages.map((message) => ({ + digestData: digestForMessage(message), + scheme: EVM_SIGN_SCHEME, + })), + { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }, + ); + return results.map((r) => toEvmRecoverableSignature(r.signature)); + } + + async signTypedData( + typedDataList: TypedDataDefinition[], + options?: EvmCallOptions, + ): Promise { + if (typedDataList.length === 0) return []; + const results = await this.signer.signDigest( + typedDataList.map((typedData) => ({ + digestData: digestForTypedData(typedData), + scheme: EVM_SIGN_SCHEME, + })), + { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }, + ); + return results.map((r) => toEvmRecoverableSignature(r.signature)); + } + + async signTransaction( + transactions: TransactionSerializable[], + options?: EvmCallOptions, + ): Promise { + if (transactions.length === 0) return []; + const results = await this.signer.signDigest( + transactions.map((transaction) => ({ + digestData: digestForTransaction(transaction), + scheme: EVM_SIGN_SCHEME, + })), + { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }, + ); + return results.map((r, i) => + HexString(serializeSignedTransaction(transactions[i]!, r.signature)), + ); + } + + async signAuthorization( + authorizations: AuthorizationRequest[], + options?: EvmCallOptions, + ): Promise { + if (authorizations.length === 0) return []; + const results = await this.signer.signDigest( + authorizations.map((authorization) => ({ + digestData: digestForAuthorization(authorization), + scheme: EVM_SIGN_SCHEME, + })), + { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }, + ); + return results.map((r, i) => + signedAuthorizationFromSignature(authorizations[i]!, r.signature), + ); + } +} diff --git a/packages/ows-signer-utils/src/evm/to-viem-account.ts b/packages/ows-signer-utils/src/evm/to-viem-account.ts new file mode 100644 index 0000000..73ac194 --- /dev/null +++ b/packages/ows-signer-utils/src/evm/to-viem-account.ts @@ -0,0 +1,73 @@ +import type { Hex, TypedDataDefinition } from "viem"; +import type { LocalAccount } from "viem/accounts"; +import type { EVMAccountAddress, SECP256K1PublicKey } from "@1shotapi/ows-types"; +import type { OWSSigner } from "../owssigner.js"; + +export type ToViemLocalAccountOptions = { + /** Prefer this address over `getCachedAddress` / `getAccountAddress` (no ceremony). */ + address?: EVMAccountAddress | Hex; + /** + * Prefer this secp256k1 public key over `getLastPublicKeyData` / `getPublicKey`. + * When address is known and this is set (or cached), no Unlock ceremony runs. + */ + publicKey?: SECP256K1PublicKey | Hex; +}; + +/** + * Build a viem `LocalAccount` backed by the Signing Layer. + * + * When `address` (or a cached address) is available, this does **not** call + * `getPublicKey` solely to fill `LocalAccount.publicKey` — pass `publicKey` from + * branding storage after the first successful ceremony when possible. + */ +export async function toViemLocalAccount( + signer: OWSSigner, + options?: ToViemLocalAccountOptions, +): Promise { + const cachedPk = signer.getLastPublicKeyData?.(); + const address = + options?.address ?? + signer.getCachedAddress?.() ?? + (await signer.evm.getAccountAddress()); + + let publicKey: Hex | undefined = + (options?.publicKey as Hex | undefined) ?? + (cachedPk?.secp256k1PublicKey as Hex | undefined); + + if (!publicKey) { + // Address was resolved without PRF; publicKey is unused by ECDSA sign paths + // but required by viem's LocalAccount shape. + publicKey = ("0x04" + "00".repeat(64)) as Hex; + } + + if (options?.address && signer.setCachedAddress) { + signer.setCachedAddress(address as EVMAccountAddress); + } + + const account = { + address: address as Hex, + type: "local", + source: "owsSigner", + publicKey, + async signMessage({ message }) { + const [signature] = await signer.evm.signMessage([message]); + return signature!; + }, + async signTransaction(transaction) { + const [signed] = await signer.evm.signTransaction([transaction]); + return signed!; + }, + async signTypedData(typedData) { + const [signature] = await signer.evm.signTypedData([ + typedData as TypedDataDefinition, + ]); + return signature!; + }, + async signAuthorization(authorization) { + const [signed] = await signer.evm.signAuthorization([authorization]); + return signed!; + }, + } satisfies LocalAccount; + + return account; +} diff --git a/packages/ows-signer-utils/src/iframe.ts b/packages/ows-signer-utils/src/iframe.ts new file mode 100644 index 0000000..70d53bb --- /dev/null +++ b/packages/ows-signer-utils/src/iframe.ts @@ -0,0 +1,265 @@ +export type CreateSignerIframeOptions = { + hidden?: boolean; +}; + +export type OverlaySignerIframeOptions = { + /** + * Element that currently holds the iframe in the DOM (usually the branding + * `#signer-container`). Defaults to `iframe.parentElement`. + */ + homeContainer?: HTMLElement; + /** Stacking order for the overlaid home container and iframe (default `"10001"`). */ + zIndex?: string; + /** Home-container background while overlaid (default `"Canvas"`). */ + background?: string; + /** Home-container border radius while overlaid (default `"6px"`). */ + borderRadius?: string; +}; + +/** Permissions Policy features for the Signing Layer iframe (set before navigation). */ +const SIGNER_IFRAME_ALLOW = [ + "publickey-credentials-get *", + "publickey-credentials-create *", + "clipboard-write *", +].join("; "); + +type StoredLayout = { + frameStyle: Record; + containerStyle: Record | null; +}; + +function captureInlineStyles(element: HTMLElement): Record { + const styles: Record = {}; + for (let i = 0; i < element.style.length; i++) { + const name = element.style.item(i); + styles[name] = element.style.getPropertyValue(name); + } + return styles; +} + +function setImportantStyle( + element: HTMLElement, + name: string, + value: string, +): void { + if (typeof element.style.setProperty === "function") { + element.style.setProperty(name, value, "important"); + return; + } + (element.style as unknown as Record)[name] = value; +} + +function restoreInlineStyles( + element: HTMLElement, + styles: Record, +): void { + if (typeof element.removeAttribute === "function") { + element.removeAttribute("style"); + } + for (const [name, value] of Object.entries(styles)) { + if (typeof element.style.setProperty === "function") { + element.style.setProperty(name, value); + } else { + (element.style as unknown as Record)[name] = value; + } + } +} + +function focusIframe(iframe: HTMLIFrameElement): void { + try { + iframe.contentWindow?.focus(); + iframe.focus(); + } catch { + // ignore focus failures + } +} + +/** + * Make the nested custody signer iframe a visible bottom strip for ceremonies + * (passkey Confirm UI, passphrase, reveal key). Call synchronously immediately + * before a signer RPC that needs signer DOM or WebAuthn. + */ +export function showSignerCeremonyPanel( + iframe: HTMLIFrameElement, +): () => void { + const container = iframe.parentElement; + const stored: StoredLayout = { + frameStyle: captureInlineStyles(iframe), + containerStyle: container ? captureInlineStyles(container) : null, + }; + + /** Half the branding viewport — room for title, description, and actions. */ + const panelHeight = "50dvh"; + const zIndex = "10001"; + + if (container) { + setImportantStyle(container, "display", "block"); + setImportantStyle(container, "position", "fixed"); + setImportantStyle(container, "top", "auto"); + setImportantStyle(container, "left", "0"); + setImportantStyle(container, "right", "0"); + setImportantStyle(container, "bottom", "0"); + setImportantStyle(container, "transform", "none"); + setImportantStyle(container, "width", "100%"); + setImportantStyle(container, "height", panelHeight); + setImportantStyle(container, "min-height", ""); + setImportantStyle(container, "max-height", ""); + setImportantStyle(container, "clip-path", "none"); + setImportantStyle(container, "overflow", "hidden"); + setImportantStyle(container, "opacity", "1"); + setImportantStyle(container, "pointer-events", "auto"); + setImportantStyle(container, "z-index", zIndex); + setImportantStyle(container, "margin", "0"); + setImportantStyle(container, "padding", "0"); + setImportantStyle(container, "background", "transparent"); + setImportantStyle(container, "border-radius", "0"); + setImportantStyle(container, "box-shadow", "none"); + } + + setImportantStyle(iframe, "display", "block"); + setImportantStyle(iframe, "position", "relative"); + setImportantStyle(iframe, "top", "auto"); + setImportantStyle(iframe, "left", "auto"); + setImportantStyle(iframe, "width", "100%"); + setImportantStyle(iframe, "height", "100%"); + setImportantStyle(iframe, "min-height", "100%"); + setImportantStyle(iframe, "clip-path", "none"); + setImportantStyle(iframe, "overflow", "hidden"); + setImportantStyle(iframe, "opacity", "1"); + setImportantStyle(iframe, "pointer-events", "auto"); + setImportantStyle(iframe, "border", "0"); + setImportantStyle(iframe, "z-index", zIndex); + + focusIframe(iframe); + + return () => { + restoreInlineStyles(iframe, stored.frameStyle); + if (container && stored.containerStyle) { + restoreInlineStyles(container, stored.containerStyle); + } + }; +} + +/** + * Visually place the signer iframe over `slot` without moving it in the DOM. + * Reparenting can reload the iframe document and drop pending signer RPCs. + * + * Positions the iframe's home container (fixed) to match `slot.getBoundingClientRect()`, + * then stretches the iframe to fill that container. Call the returned function to + * restore prior inline styles. + */ +export function overlaySignerIframe( + iframe: HTMLIFrameElement, + slot: HTMLElement, + options: OverlaySignerIframeOptions = {}, +): () => void { + const homeContainer = options.homeContainer ?? iframe.parentElement; + if (!homeContainer) { + throw new Error( + "overlaySignerIframe: iframe has no parentElement; pass options.homeContainer", + ); + } + + const zIndex = options.zIndex ?? "10001"; + const background = options.background ?? "Canvas"; + const borderRadius = options.borderRadius ?? "6px"; + + const savedFrameStyles = captureInlineStyles(iframe); + const savedContainerStyles = captureInlineStyles(homeContainer); + const rect = slot.getBoundingClientRect(); + + setImportantStyle(homeContainer, "display", "block"); + setImportantStyle(homeContainer, "position", "fixed"); + setImportantStyle(homeContainer, "top", `${rect.top}px`); + setImportantStyle(homeContainer, "left", `${rect.left}px`); + setImportantStyle(homeContainer, "width", `${Math.max(rect.width, 1)}px`); + setImportantStyle(homeContainer, "height", `${Math.max(rect.height, 1)}px`); + setImportantStyle(homeContainer, "clip-path", "none"); + setImportantStyle(homeContainer, "overflow", "visible"); + setImportantStyle(homeContainer, "opacity", "1"); + setImportantStyle(homeContainer, "pointer-events", "auto"); + setImportantStyle(homeContainer, "z-index", zIndex); + setImportantStyle(homeContainer, "margin", "0"); + setImportantStyle(homeContainer, "padding", "0"); + setImportantStyle(homeContainer, "background", background); + setImportantStyle(homeContainer, "border-radius", borderRadius); + + setImportantStyle(iframe, "display", "block"); + setImportantStyle(iframe, "position", "absolute"); + setImportantStyle(iframe, "top", "0"); + setImportantStyle(iframe, "left", "0"); + setImportantStyle(iframe, "width", "100%"); + setImportantStyle(iframe, "height", "100%"); + setImportantStyle(iframe, "min-height", "0"); + setImportantStyle(iframe, "border", "0"); + setImportantStyle(iframe, "clip-path", "none"); + setImportantStyle(iframe, "overflow", "visible"); + setImportantStyle(iframe, "opacity", "1"); + setImportantStyle(iframe, "pointer-events", "auto"); + setImportantStyle(iframe, "z-index", zIndex); + + focusIframe(iframe); + + return () => { + restoreInlineStyles(iframe, savedFrameStyles); + restoreInlineStyles(homeContainer, savedContainerStyles); + }; +} + +export function createSignerIframe( + container: HTMLElement, + signerUrl: string, + options: CreateSignerIframeOptions = {}, +): Promise { + const hidden = options.hidden !== false; + const signerOrigin = getSignerOrigin(signerUrl); + if (signerOrigin !== window.location.origin) { + return Promise.reject( + new Error( + `OWS signer URL must be same-origin as Branding (got ${signerOrigin}, expected ${window.location.origin})`, + ), + ); + } + + return new Promise((resolve, reject) => { + const iframe = document.createElement("iframe"); + iframe.src = signerUrl; + iframe.allow = SIGNER_IFRAME_ALLOW; + iframe.title = "OWS custody signer"; + iframe.style.border = "0"; + + if (hidden) { + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.style.position = "absolute"; + iframe.style.overflow = "hidden"; + iframe.style.clipPath = "inset(50%)"; + } + + iframe.addEventListener( + "load", + () => { + resolve(iframe); + }, + { once: true }, + ); + + iframe.addEventListener( + "error", + () => { + reject(new Error(`Failed to load custody signer iframe: ${signerUrl}`)); + }, + { once: true }, + ); + + container.appendChild(iframe); + }); +} + +export function getSignerOrigin(signerUrl: string): string { + const base = + typeof window !== "undefined" && window.location?.href + ? window.location.href + : undefined; + return new URL(signerUrl, base).origin; +} diff --git a/packages/ows-signer-utils/src/index.ts b/packages/ows-signer-utils/src/index.ts new file mode 100644 index 0000000..5dc420a --- /dev/null +++ b/packages/ows-signer-utils/src/index.ts @@ -0,0 +1,77 @@ +export { OWSSigner, type OWSSignerOptions } from "./owssigner.js"; +export { + createSignerIframe, + getSignerOrigin, + overlaySignerIframe, + showSignerCeremonyPanel, + type CreateSignerIframeOptions, + type OverlaySignerIframeOptions, +} from "./iframe.js"; +export { + SignHelper, + parseTypedData, + prepareEvmTransaction, + type Eip1193Handler, + type Eip1193SignHandlers, + type SignHelperChainRpc, + type SignHelperDisplaySession, + type SignHelperOptions, + type SignHelperSigner, + type SignHelperWallet, +} from "./eip1193/sign-helper.js"; +export type { + PersonalSignApprovalRequest, + SendTransactionApprovalRequest, + SignTypedDataApprovalRequest, + SignTypedDataPayload, +} from "./eip1193/approval-types.js"; +export { + OwsSignerError, + OwsInvalidRequestError, + OwsNotAllowedError, + OwsSignDeniedError, +} from "./errors.js"; +export { EvmSigner, type EvmCallOptions } from "./evm/namespace.js"; +export { + toViemLocalAccount, + type ToViemLocalAccountOptions, +} from "./evm/to-viem-account.js"; +export { + SolanaSigner, + type SolanaCallOptions, + type ISolanaSignTransactionInput, + type ISolanaSignedTransaction, +} from "./solana/namespace.js"; +export { addressFromEd25519PublicKey } from "./solana/address.js"; +export { + toKitPartialSigner, + type ToKitPartialSignerOptions, + type OwsKitPartialSigner, +} from "./solana/to-kit-signer.js"; +export { + digestDataForSolanaBytes, + solanaSignatureFromHex, + solanaSignatureToBytes, + SOLANA_SIGN_SCHEME, +} from "./solana/marshal.js"; +export { BitcoinSigner, type BitcoinCallOptions } from "./bitcoin/namespace.js"; +export { + addressFromSecp256k1PublicKey, + compressSecp256k1PublicKey, + bitcoinNetworkForChainId, +} from "./bitcoin/address.js"; +export { + prepareBitcoinTransaction, + finalizeBitcoinTransaction, + type IBitcoinTransactionInput, + type IBitcoinTransactionOutput, + type IBitcoinUnsignedTransaction, + type IBitcoinSignedTransactionResult, + type IPreparedBitcoinTransaction, +} from "./bitcoin/marshal.js"; +export { + keyDerivedDataFromEvent, + publicKeyDataFromEvent, + credentialCreatedDataFromEvent, +} from "./rpc/client.js"; +export { COSEToSPKIPublicKey } from "./webauthn/cose-to-spki.js"; diff --git a/packages/ows-signer-utils/src/owssigner.ts b/packages/ows-signer-utils/src/owssigner.ts new file mode 100644 index 0000000..fda3816 --- /dev/null +++ b/packages/ows-signer-utils/src/owssigner.ts @@ -0,0 +1,608 @@ +import { + Base64UrlEncodedString, + BITCOIN_MAINNET_CHAIN_ID, + BITCOIN_TESTNET_CHAIN_ID, + ConversionUtils, + CredentialId, + EVMAccountAddress, + JSONString, + SolanaAccountAddress, + AES256CipherTextEnvelope, +} from "@1shotapi/ows-types"; +import type { + BitcoinChainId, + BitcoinSegwitAccountAddress, + ED25519PublicKey, + SECP256K1PublicKey, + CeremonyUiParams, + CreateCredentialOptions, + CredentialCreatedData, + DigestSignedData, + DigestSignedResult, + ExecuteBatchParams, + ExecuteBatchResult, + GetPublicKeyParams, + PublicKeyData, + RecoveryCeremonyOptions, + RecoveryDataCreatedData, + SignScheme, + VersionData, + EncryptAES256Result, + DecryptAES256Result, + IOWSSigner, + WebAuthnAssertionFields, +} from "@1shotapi/ows-types"; +import type { Hex } from "viem"; +import { publicKeyToAddress } from "viem/utils"; +import { createSignerIframe, getSignerOrigin, showSignerCeremonyPanel } from "./iframe.js"; +import { EvmSigner } from "./evm/namespace.js"; +import { addressFromEd25519PublicKey } from "./solana/address.js"; +import { SolanaSigner } from "./solana/namespace.js"; +import { addressFromSecp256k1PublicKey } from "./bitcoin/address.js"; +import { BitcoinSigner } from "./bitcoin/namespace.js"; +import { + keyDerivedDataFromEvent, + credentialCreatedDataFromEvent, + publicKeyDataFromEvent, + SignerRpcClient, +} from "./rpc/client.js"; + +export type OWSSignerOptions = { + credentialId?: CredentialId; + hidden?: boolean; + /** Fired when the branding ceremony host strip opens or closes. */ + onCeremonyPanel?: (open: boolean) => void; +}; + +const DEFAULT_CEREMONY_UI: Required = { + explanationHeader: "Confirm passkey", + explanationText: "Your device will ask for a passkey to continue.", + confirmButtonText: "Continue", + denyButtonText: "Cancel", +}; + +function withCeremonyDefaults( + fields?: CeremonyUiParams, +): Required { + return { + explanationHeader: + fields?.explanationHeader ?? DEFAULT_CEREMONY_UI.explanationHeader, + explanationText: + fields?.explanationText ?? DEFAULT_CEREMONY_UI.explanationText, + confirmButtonText: + fields?.confirmButtonText ?? DEFAULT_CEREMONY_UI.confirmButtonText, + denyButtonText: + fields?.denyButtonText ?? DEFAULT_CEREMONY_UI.denyButtonText, + }; +} + +/** + * Decode Signing Layer wire assertion (base64url fields) into branded values. + */ +function webAuthnAssertionFromEvent( + value: unknown, +): WebAuthnAssertionFields | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if ( + typeof record.authenticatorData !== "string" || + typeof record.clientDataJSON !== "string" || + typeof record.signature !== "string" || + typeof record.credentialId !== "string" + ) { + return undefined; + } + try { + const authenticatorData = ConversionUtils.bytesToHex( + ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(record.authenticatorData), + ), + ); + const signature = ConversionUtils.bytesToHex( + ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(record.signature), + ), + ); + const clientDataUtf8 = new TextDecoder().decode( + ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(record.clientDataJSON), + ), + ); + JSON.parse(clientDataUtf8); + return { + authenticatorData, + clientDataJSON: JSONString(clientDataUtf8), + signature, + credentialId: CredentialId(record.credentialId), + }; + } catch { + return undefined; + } +} + +export class OWSSigner implements IOWSSigner { + readonly evm: EvmSigner; + readonly solana: SolanaSigner; + readonly bitcoin: BitcoinSigner; + + private readonly rpc: SignerRpcClient; + private credentialId?: CredentialId; + private cachedAddress?: EVMAccountAddress; + private cachedSolanaAddress?: SolanaAccountAddress; + private cachedBitcoinMainnetAddress?: BitcoinSegwitAccountAddress; + private cachedBitcoinTestnetAddress?: BitcoinSegwitAccountAddress; + private lastPublicKeyData?: PublicKeyData; + private readonly onCeremonyPanel?: (open: boolean) => void; + + private constructor( + private readonly iframe: HTMLIFrameElement, + rpc: SignerRpcClient, + options?: OWSSignerOptions, + ) { + this.rpc = rpc; + this.credentialId = options?.credentialId; + this.onCeremonyPanel = options?.onCeremonyPanel; + this.evm = new EvmSigner(this); + this.solana = new SolanaSigner(this); + this.bitcoin = new BitcoinSigner(this); + } + + static async create( + container: HTMLElement, + signerUrl: string, + options?: OWSSignerOptions, + ): Promise { + const iframe = await createSignerIframe(container, signerUrl, { + hidden: options?.hidden, + }); + const signerOrigin = getSignerOrigin(signerUrl); + const rpc = new SignerRpcClient(iframe, signerOrigin); + return new OWSSigner(iframe, rpc, options); + } + + getCredentialId(): CredentialId | undefined { + return this.credentialId; + } + + /** + * Drop session credential id, address cache, and Signing Layer recovery + * session (e.g. branding “change account”). Cancels in-flight signer RPCs so + * late `KeyDerived` cannot refill caches. + * Does not clear Branding Layer localStorage — callers clear that separately. + */ + async clearSession(): Promise { + this.rpc.rejectAllPending("OWSSigner session cleared"); + this.credentialId = undefined; + this.cachedAddress = undefined; + this.cachedSolanaAddress = undefined; + this.cachedBitcoinMainnetAddress = undefined; + this.cachedBitcoinTestnetAddress = undefined; + this.lastPublicKeyData = undefined; + try { + await this.clearRecoverySession(); + } catch { + // Iframe may already be gone or recovery already inactive. + } + } + + getCachedAddress(): EVMAccountAddress | undefined { + return this.cachedAddress; + } + + setCachedAddress(address: EVMAccountAddress): void { + this.cachedAddress = address; + } + + getCachedSolanaAddress(): SolanaAccountAddress | undefined { + return this.cachedSolanaAddress; + } + + setCachedSolanaAddress(address: SolanaAccountAddress): void { + this.cachedSolanaAddress = address; + } + + getCachedBitcoinSegwitAddress( + chainId: BitcoinChainId = BITCOIN_MAINNET_CHAIN_ID, + ): BitcoinSegwitAccountAddress | undefined { + return chainId === BITCOIN_MAINNET_CHAIN_ID + ? this.cachedBitcoinMainnetAddress + : this.cachedBitcoinTestnetAddress; + } + + setCachedBitcoinSegwitAddress( + chainId: BitcoinChainId, + address: BitcoinSegwitAccountAddress, + ): void { + if (chainId === BITCOIN_MAINNET_CHAIN_ID) { + this.cachedBitcoinMainnetAddress = address; + } else { + this.cachedBitcoinTestnetAddress = address; + } + } + + /** Last `getPublicKey` result in this session — avoids repeat WebAuthn for holder binding. */ + getLastPublicKeyData(): PublicKeyData | undefined { + return this.lastPublicKeyData; + } + + destroy(): void { + this.rpc.destroy(); + this.iframe.remove(); + } + + private onKeyDerived = (data: Record): void => { + const derived = keyDerivedDataFromEvent(data); + if (derived) { + this.lastPublicKeyData = { + cosePublicKey: null, + secp256k1PublicKey: derived.secp256k1PublicKey, + ed25519PublicKey: derived.ed25519PublicKey, + credentialId: this.credentialId, + }; + this.cacheAddressesFromPublicKeys( + derived.secp256k1PublicKey, + derived.ed25519PublicKey, + ); + } + }; + + private cacheAddressesFromPublicKeys( + secp256k1PublicKey: SECP256K1PublicKey, + ed25519PublicKey: ED25519PublicKey, + ): void { + // Always overwrite — derived keys are the source of truth. Write-once + // left a stale EVM/Solana/BTC cache after Change Account when a late + // KeyDerived refilled the cache before a new getPublicKey. + this.cachedAddress = EVMAccountAddress( + publicKeyToAddress(secp256k1PublicKey), + ); + this.cachedSolanaAddress = addressFromEd25519PublicKey(ed25519PublicKey); + this.cachedBitcoinMainnetAddress = addressFromSecp256k1PublicKey( + secp256k1PublicKey, + BITCOIN_MAINNET_CHAIN_ID, + ); + this.cachedBitcoinTestnetAddress = addressFromSecp256k1PublicKey( + secp256k1PublicKey, + BITCOIN_TESTNET_CHAIN_ID, + ); + } + + private cacheAddressFromPublicKey(publicKey: SECP256K1PublicKey): void { + this.cachedAddress = EVMAccountAddress(publicKeyToAddress(publicKey)); + this.cachedBitcoinMainnetAddress = addressFromSecp256k1PublicKey( + publicKey, + BITCOIN_MAINNET_CHAIN_ID, + ); + this.cachedBitcoinTestnetAddress = addressFromSecp256k1PublicKey( + publicKey, + BITCOIN_TESTNET_CHAIN_ID, + ); + } + + private async withCeremonyPanel(run: () => Promise): Promise { + const restore = showSignerCeremonyPanel(this.iframe); + this.onCeremonyPanel?.(true); + try { + return await run(); + } finally { + restore(); + this.onCeremonyPanel?.(false); + } + } + + async getVersion(): Promise { + return this.rpc.request("getVersion", undefined, { + terminalEvent: "Version", + }); + } + + async createCredential( + name: string, + options?: CreateCredentialOptions, + ): Promise { + const ceremony = withCeremonyDefaults(options); + return this.withCeremonyPanel(async () => { + const result = await this.rpc.request>( + "createCredential", + { + name, + options: { + ...options, + ...ceremony, + }, + }, + { + terminalEvent: "CredentialCreated", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + const created = credentialCreatedDataFromEvent(result); + if (!created) { + throw new Error("createCredential: invalid CredentialCreated payload"); + } + this.credentialId = created.credentialId; + if (created.secp256k1PublicKey) { + this.cacheAddressFromPublicKey(created.secp256k1PublicKey); + } + return created; + }); + } + + async signDigest( + digests: Array<{ + digestData: Hex; + scheme?: SignScheme; + }>, + options?: CeremonyUiParams & { credentialId?: string }, + ): Promise { + const ceremony = withCeremonyDefaults(options); + return this.withCeremonyPanel(async () => { + const result = await this.rpc.request( + "signDigest", + { + digests: digests.map((item) => ({ + digestData: item.digestData, + scheme: item.scheme ?? "secp256k1-ecdsa-recoverable", + })), + credentialId: options?.credentialId ?? this.credentialId, + ...ceremony, + }, + { + terminalEvent: "DigestSigned", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + const firstCredentialId = result.results.find((r) => r.credentialId) + ?.credentialId; + if (firstCredentialId) { + this.credentialId = firstCredentialId; + } + return result.results; + }); + } + + /** + * Mixed ceremony: digests, AES encrypt/decrypt, public key, and/or + * WebAuthn challenge auth under one passkey assertion. + */ + async executeBatch(params: ExecuteBatchParams): Promise { + const ceremony = withCeremonyDefaults(params); + return this.withCeremonyPanel(async () => { + const result = await this.rpc.request( + "executeBatch", + { + ...params, + ...ceremony, + credentialId: params.credentialId ?? this.credentialId, + }, + { + terminalEvent: "BatchExecuted", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + if (result.credentialId) { + this.credentialId = result.credentialId; + } + if (result.publicKey) { + this.lastPublicKeyData = result.publicKey; + this.cacheAddressesFromPublicKeys( + result.publicKey.secp256k1PublicKey, + result.publicKey.ed25519PublicKey, + ); + } + const assertion = webAuthnAssertionFromEvent(result.assertion); + if (params.challenge !== undefined && !assertion) { + throw new Error( + "executeBatch: challenge was supplied but assertion fields missing", + ); + } + return assertion ? { ...result, assertion } : { ...result, assertion: undefined }; + }); + } + + async getPublicKey( + params: GetPublicKeyParams & { challenge: `0x${string}` }, + ): Promise; + async getPublicKey( + params?: GetPublicKeyParams, + ): Promise; + async getPublicKey( + params?: GetPublicKeyParams, + ): Promise { + const hasChallenge = params?.challenge !== undefined; + const credentialId = params?.discoverable + ? undefined + : (params?.credentialId ?? this.credentialId); + const ceremony = withCeremonyDefaults(params); + + return this.withCeremonyPanel(async () => { + const result = await this.rpc.request>( + "getPublicKey", + { + credentialId, + challenge: params?.challenge, + ...ceremony, + }, + { + terminalEvent: "PublicKey", + alsoWaitFor: hasChallenge ? ["ChallengeSigned"] : undefined, + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + + const publicKeyData = publicKeyDataFromEvent(result); + if (!publicKeyData) { + throw new Error("getPublicKey: invalid PublicKey payload"); + } + + if (publicKeyData.credentialId) { + this.credentialId = publicKeyData.credentialId; + } + + this.lastPublicKeyData = publicKeyData; + + this.cacheAddressesFromPublicKeys( + publicKeyData.secp256k1PublicKey, + publicKeyData.ed25519PublicKey, + ); + + const assertion = webAuthnAssertionFromEvent(result.assertion); + if (hasChallenge && !assertion) { + throw new Error( + "getPublicKey: challenge was supplied but assertion fields missing", + ); + } + return assertion ? { ...publicKeyData, assertion } : publicKeyData; + }); + } + + async createRecoveryData( + passwordText: string, + buttonText: string, + minPasswordLength: number, + options?: RecoveryCeremonyOptions, + ): Promise { + const ceremony = withCeremonyDefaults(options); + return this.withCeremonyPanel(async () => + this.rpc.request( + "createRecoveryData", + { + passwordText, + buttonText, + minPasswordLength, + credentialId: options?.credentialId ?? this.credentialId, + ...ceremony, + }, + { + terminalEvent: "RecoveryDataCreated", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ), + ); + } + + async recoverKey( + aes256EncryptedPrivateKey: string, + passwordText: string, + buttonText: string, + options?: RecoveryCeremonyOptions, + ): Promise { + const ceremony = withCeremonyDefaults(options); + const credentialId = options?.credentialId; + await this.withCeremonyPanel(async () => { + await this.rpc.request<{ recoverySessionActive?: true; rebound?: boolean }>( + "recoverKey", + { + aes256EncryptedPrivateKey, + passwordText, + buttonText, + credentialId, + ...ceremony, + }, + { + terminalEvent: credentialId + ? "RecoverySessionCleared" + : "RecoverySessionStarted", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + }); + } + + async revealPrivateKey(options?: RecoveryCeremonyOptions): Promise { + const ceremony = withCeremonyDefaults(options); + await this.withCeremonyPanel(async () => { + await this.rpc.request>( + "revealPrivateKey", + { + credentialId: options?.credentialId ?? this.credentialId, + ...ceremony, + }, + { + // KeyDerived is intermediate (cache addresses); panel stays open until + // the user dismisses the key UI (PrivateKeyRevealed). + terminalEvent: "PrivateKeyRevealed", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + }); + } + + /** + * Prompt for a hex private key in the Signing Layer and start a recovery + * session (same terminal events as `recoverKey` without a backup envelope). + */ + async importPrivateKey(): Promise { + await this.withCeremonyPanel(async () => { + await this.rpc.request<{ recoverySessionActive?: true }>( + "importPrivateKey", + undefined, + { + terminalEvent: "RecoverySessionStarted", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + }); + } + + async clearRecoverySession(): Promise { + await this.rpc.request>( + "clearRecoverySession", + undefined, + { terminalEvent: "RecoverySessionCleared" }, + ); + } + + /** + * Batch-encrypt plaintexts with AES-256-GCM keyed from the wallet secp256k1 + * scalar (same material as `signDigest`, HKDF info `ows-v1/aes256-gcm`). + * One passkey ceremony (or recovery session) covers the whole batch. + */ + async encryptAES256( + plaintexts: string[], + options?: CeremonyUiParams & { credentialId?: string }, + ): Promise { + const ceremony = withCeremonyDefaults(options); + return this.withCeremonyPanel(async () => { + const result = await this.rpc.request( + "encryptAES256", + { + plaintexts, + credentialId: options?.credentialId ?? this.credentialId, + ...ceremony, + }, + { + terminalEvent: "AES256Encrypted", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + return result.ciphertexts.map((c) => AES256CipherTextEnvelope(c)); + }); + } + + /** + * Batch-decrypt `ows-aes1:` AES-256-GCM envelopes. One passkey ceremony + * (or recovery session) covers the whole batch. + */ + async decryptAES256( + ciphertexts: AES256CipherTextEnvelope[], + options?: CeremonyUiParams & { credentialId?: string }, + ): Promise { + const ceremony = withCeremonyDefaults(options); + return this.withCeremonyPanel(async () => { + const result = await this.rpc.request( + "decryptAES256", + { + ciphertexts: ciphertexts.map(String), + credentialId: options?.credentialId ?? this.credentialId, + ...ceremony, + }, + { + terminalEvent: "AES256Decrypted", + onIntermediate: (_event, data) => this.onKeyDerived(data), + }, + ); + return result.plaintexts; + }); + } +} diff --git a/packages/ows-signer-utils/src/rpc/client.ts b/packages/ows-signer-utils/src/rpc/client.ts new file mode 100644 index 0000000..b04a80b --- /dev/null +++ b/packages/ows-signer-utils/src/rpc/client.ts @@ -0,0 +1,264 @@ +import { + OwsInvalidRequestError, + OwsNotAllowedError, + OwsSignDeniedError, +} from "../errors.js"; +import { + API_VERSION, + type SignerEvent, + type SignerEventMessage, + type SignerMethod, + type SignerRequest, +} from "@1shotapi/ows-types"; + +export { keyDerivedDataFromEvent } from "./parse-public-keys.js"; +export { + publicKeyDataFromEvent, + credentialCreatedDataFromEvent, +} from "./parse-public-keys.js"; + +export type RequestOptions = { + terminalEvent: SignerEvent; + /** After terminalEvent, wait briefly for these events and merge their data. */ + alsoWaitFor?: SignerEvent[]; + alsoWaitForTimeoutMs?: number; + onIntermediate?: (event: SignerEvent, data: Record) => void; +}; + +type PendingRequest> = { + method: SignerMethod; + terminalEvent: SignerEvent; + alsoWaitFor?: SignerEvent[]; + alsoWaitForTimeoutMs: number; + accumulated: Record; + receivedAlsoWaitFor: Set; + resolve: (data: TData) => void; + reject: (error: Error) => void; + onIntermediate?: RequestOptions["onIntermediate"]; + alsoWaitForTimeoutId?: ReturnType; + waitingForAlsoWaitFor: boolean; +}; + +/** + * Branding → Signing RPC over `postMessage`. + * + * No client-imposed timeout: requests wait on the signer Confirm UI and + * WebAuthn, which are user-paced. A pending request settles on its terminal + * event, or rejects via {@link SignerRpcClient.rejectAllPending} / + * {@link SignerRpcClient.destroy} (session clear, teardown, or a superseding + * request's cancel). + */ +export class SignerRpcClient { + private nextCorrelationId = 1; + private readonly pending = new Map>>(); + private readonly listener: (event: MessageEvent) => void; + + constructor( + private readonly iframe: HTMLIFrameElement, + readonly signerOrigin: string, + ) { + this.listener = (event: MessageEvent) => { + this.handleMessage(event); + }; + window.addEventListener("message", this.listener); + } + + request>( + method: SignerMethod, + params: Record | undefined, + options: RequestOptions, + ): Promise { + const correlationId = String(this.nextCorrelationId++); + + return new Promise((resolve, reject) => { + // Supersede any stuck Confirm / WebAuthn from a prior RPC. + this.postCancel(); + + this.pending.set(correlationId, { + method, + terminalEvent: options.terminalEvent, + alsoWaitFor: options.alsoWaitFor, + alsoWaitForTimeoutMs: options.alsoWaitForTimeoutMs ?? 100, + accumulated: {}, + receivedAlsoWaitFor: new Set(), + resolve: resolve as (data: Record) => void, + reject, + onIntermediate: options.onIntermediate, + waitingForAlsoWaitFor: false, + }); + + const message: SignerRequest = { + v: API_VERSION, + kind: "request", + method, + correlationId, + params, + }; + + const target = this.iframe.contentWindow; + if (!target) { + this.pending.delete(correlationId); + reject(new Error("Signer iframe contentWindow unavailable")); + return; + } + + target.postMessage(message, this.signerOrigin); + }); + } + + /** + * Reject every in-flight RPC and cancel the signer ceremony panel. + * Used by {@link destroy} and branding `clearSession` so late `KeyDerived` + * events for abandoned correlation ids are ignored. + */ + rejectAllPending(reason: string): void { + this.postCancel(); + for (const [id, pending] of this.pending) { + if (pending.alsoWaitForTimeoutId) { + clearTimeout(pending.alsoWaitForTimeoutId); + } + pending.reject(new Error(reason)); + this.pending.delete(id); + } + } + + destroy(): void { + window.removeEventListener("message", this.listener); + this.rejectAllPending("SignerRpcClient destroyed"); + } + + /** Tell the signer to drop a stuck Confirm wait / ceremony lock. */ + private postCancel(): void { + const target = this.iframe.contentWindow; + if (!target) return; + target.postMessage( + { v: API_VERSION, kind: "cancel" }, + this.signerOrigin, + ); + } + + private handleMessage(event: MessageEvent): void { + if (event.origin !== this.signerOrigin) return; + if (event.source !== this.iframe.contentWindow) return; + + const data = event.data; + if (!isSignerEventMessage(data)) return; + + const correlationId = data.correlationId; + if (correlationId === undefined) return; + + const pending = this.pending.get(correlationId); + if (!pending) return; + + if (data.event === "SignDenied") { + this.finishPending( + correlationId, + pending, + new OwsSignDeniedError( + String(data.data.reason ?? "signDenied"), + typeof data.data.reason === "string" ? data.data.reason : undefined, + ), + ); + return; + } + + if (data.event === "NotAllowed") { + this.finishPending( + correlationId, + pending, + new OwsNotAllowedError( + String(data.data.reason ?? "notAllowed"), + typeof data.data.reason === "string" ? data.data.reason : undefined, + ), + ); + return; + } + + if (data.event === "InvalidRequest") { + this.finishPending( + correlationId, + pending, + new OwsInvalidRequestError( + String(data.data.reason ?? "invalidRequest"), + typeof data.data.reason === "string" ? data.data.reason : undefined, + ), + ); + return; + } + + if (data.event === "KeyDerived" && pending.onIntermediate) { + pending.onIntermediate(data.event, data.data); + } else if (pending.onIntermediate) { + pending.onIntermediate(data.event, data.data); + } + + Object.assign(pending.accumulated, data.data); + + if (pending.alsoWaitFor?.includes(data.event)) { + pending.receivedAlsoWaitFor.add(data.event); + } + + if (data.event === pending.terminalEvent) { + if (pending.alsoWaitFor?.length && !pending.waitingForAlsoWaitFor) { + pending.waitingForAlsoWaitFor = true; + const allReceived = pending.alsoWaitFor.every((event) => + pending.receivedAlsoWaitFor.has(event), + ); + if (allReceived) { + this.finishPending(correlationId, pending, undefined, pending.accumulated); + return; + } + pending.alsoWaitForTimeoutId = setTimeout(() => { + this.finishPending(correlationId, pending, undefined, pending.accumulated); + }, pending.alsoWaitForTimeoutMs); + return; + } + this.finishPending(correlationId, pending, undefined, pending.accumulated); + return; + } + + if ( + pending.waitingForAlsoWaitFor && + pending.alsoWaitFor?.includes(data.event) + ) { + const allReceived = pending.alsoWaitFor.every((event) => + pending.receivedAlsoWaitFor.has(event), + ); + if (allReceived) { + if (pending.alsoWaitForTimeoutId) { + clearTimeout(pending.alsoWaitForTimeoutId); + } + this.finishPending(correlationId, pending, undefined, pending.accumulated); + } + } + } + + private finishPending( + correlationId: string, + pending: PendingRequest>, + error?: Error, + data?: Record, + ): void { + if (pending.alsoWaitForTimeoutId) { + clearTimeout(pending.alsoWaitForTimeoutId); + } + this.pending.delete(correlationId); + if (error) { + pending.reject(error); + return; + } + pending.resolve(data ?? pending.accumulated); + } +} + +export function isSignerEventMessage(data: unknown): data is SignerEventMessage { + if (!data || typeof data !== "object") return false; + const msg = data as Record; + return ( + msg.v === API_VERSION && + msg.kind === "event" && + typeof msg.event === "string" && + typeof msg.data === "object" && + msg.data !== null + ); +} diff --git a/packages/ows-signer-utils/src/rpc/parse-public-keys.ts b/packages/ows-signer-utils/src/rpc/parse-public-keys.ts new file mode 100644 index 0000000..0eb540b --- /dev/null +++ b/packages/ows-signer-utils/src/rpc/parse-public-keys.ts @@ -0,0 +1,78 @@ +import { + COSEPublicKeySchema, + CredentialIdSchema, + ED25519PublicKeySchema, + SECP256K1PublicKeySchema, + type COSEPublicKey, + type CredentialId, + type ED25519PublicKey, + type SECP256K1PublicKey, +} from "@1shotapi/ows-types"; +import type { + CredentialCreatedData, + KeyDerivedData, + PublicKeyData, +} from "@1shotapi/ows-types"; + +function parseCosePublicKey(value: unknown): COSEPublicKey | null { + const parsed = COSEPublicKeySchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function parseSecp256k1PublicKey(value: unknown): SECP256K1PublicKey | null { + const parsed = SECP256K1PublicKeySchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function parseEd25519PublicKey(value: unknown): ED25519PublicKey | null { + const parsed = ED25519PublicKeySchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function parseCredentialId(value: unknown): CredentialId | undefined { + const parsed = CredentialIdSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} + +export function keyDerivedDataFromEvent( + data: Record, +): KeyDerivedData | null { + const secp256k1PublicKey = parseSecp256k1PublicKey(data.secp256k1PublicKey); + const ed25519PublicKey = parseEd25519PublicKey(data.ed25519PublicKey); + if (!secp256k1PublicKey || !ed25519PublicKey) { + return null; + } + return { secp256k1PublicKey, ed25519PublicKey }; +} + +export function publicKeyDataFromEvent( + data: Record, +): PublicKeyData | null { + const secp256k1PublicKey = parseSecp256k1PublicKey(data.secp256k1PublicKey); + const ed25519PublicKey = parseEd25519PublicKey(data.ed25519PublicKey); + if (!secp256k1PublicKey || !ed25519PublicKey) { + return null; + } + + return { + cosePublicKey: parseCosePublicKey(data.cosePublicKey), + secp256k1PublicKey, + ed25519PublicKey, + credentialId: parseCredentialId(data.credentialId), + }; +} + +export function credentialCreatedDataFromEvent( + data: Record, +): CredentialCreatedData | null { + const credentialId = parseCredentialId(data.credentialId); + if (!credentialId) { + return null; + } + const secp256k1PublicKey = parseSecp256k1PublicKey(data.secp256k1PublicKey); + return { + credentialId, + cosePublicKey: parseCosePublicKey(data.cosePublicKey), + ...(secp256k1PublicKey ? { secp256k1PublicKey } : {}), + }; +} diff --git a/packages/ows-signer-utils/src/solana/address.ts b/packages/ows-signer-utils/src/solana/address.ts new file mode 100644 index 0000000..8ed1ddb --- /dev/null +++ b/packages/ows-signer-utils/src/solana/address.ts @@ -0,0 +1,16 @@ +import { + ConversionUtils, + HexString, + SolanaAccountAddress, + type ED25519PublicKey, +} from "@1shotapi/ows-types"; +import { base58 } from "@scure/base"; + +/** Solana address = base58-encoded ed25519 public key (32 bytes). */ +export function addressFromEd25519PublicKey( + ed25519PublicKey: ED25519PublicKey, +): SolanaAccountAddress { + return SolanaAccountAddress( + base58.encode(ConversionUtils.hexToBytes(HexString(ed25519PublicKey))), + ); +} diff --git a/packages/ows-signer-utils/src/solana/marshal.ts b/packages/ows-signer-utils/src/solana/marshal.ts new file mode 100644 index 0000000..b771587 --- /dev/null +++ b/packages/ows-signer-utils/src/solana/marshal.ts @@ -0,0 +1,29 @@ +import { base58 } from "@scure/base"; +import { + ConversionUtils, + HexString, + SolanaSignature, +} from "@1shotapi/ows-types"; + +const SOLANA_SIGN_SCHEME = "ed25519" as const; + +/** Hex payload for Signing Layer `signDigest` with scheme `ed25519`. */ +export function digestDataForSolanaBytes(bytes: Uint8Array) { + return ConversionUtils.bytesToHex(bytes); +} + +export function solanaSignatureFromHex(signatureHex: `0x${string}`): SolanaSignature { + const bytes = ConversionUtils.hexToBytes(HexString(signatureHex)); + if (bytes.length !== 64) { + throw new Error( + `Solana ed25519 signature must be 64 bytes, got ${bytes.length}`, + ); + } + return SolanaSignature(base58.encode(bytes)); +} + +export function solanaSignatureToBytes(signature: SolanaSignature): Uint8Array { + return base58.decode(signature); +} + +export { SOLANA_SIGN_SCHEME }; diff --git a/packages/ows-signer-utils/src/solana/namespace.ts b/packages/ows-signer-utils/src/solana/namespace.ts new file mode 100644 index 0000000..1868a9e --- /dev/null +++ b/packages/ows-signer-utils/src/solana/namespace.ts @@ -0,0 +1,108 @@ +import type { + CeremonyUiParams, + CredentialId, + SolanaAccountAddress, + SolanaSignature, +} from "@1shotapi/ows-types"; +import type { OWSSigner } from "../owssigner.js"; +import { addressFromEd25519PublicKey } from "./address.js"; +import { + digestDataForSolanaBytes, + SOLANA_SIGN_SCHEME, + solanaSignatureFromHex, + solanaSignatureToBytes, +} from "./marshal.js"; + +export type SolanaCallOptions = CeremonyUiParams & { + credentialId?: CredentialId; +}; + +/** Compiled Solana transaction message bytes (Kit `messageBytes` / Standard). */ +export type ISolanaSignTransactionInput = { + messageBytes: Uint8Array; +}; + +export type ISolanaSignedTransaction = { + signature: SolanaSignature; + signatureBytes: Uint8Array; +}; + +export class SolanaSigner { + constructor(private readonly signer: OWSSigner) {} + + async getAccountAddress( + options?: SolanaCallOptions, + ): Promise { + const cached = this.signer.getCachedSolanaAddress(); + if (cached) return cached; + + const publicKey = await this.signer.getPublicKey({ + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }); + const address = addressFromEd25519PublicKey(publicKey.ed25519PublicKey); + this.signer.setCachedSolanaAddress(address); + return address; + } + + /** + * Sign one or more arbitrary messages (Ed25519 over the raw bytes). + * Used by Wallet Standard `solana:signMessage` and SIWS (after Branding + * formats the message). + */ + async signMessage( + messages: readonly Uint8Array[], + options?: SolanaCallOptions, + ): Promise { + if (messages.length === 0) return []; + const results = await this.signer.signDigest( + messages.map((message) => ({ + digestData: digestDataForSolanaBytes(message), + scheme: SOLANA_SIGN_SCHEME, + })), + { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }, + ); + return results.map((r) => solanaSignatureFromHex(r.signature)); + } + + /** + * Sign one or more compiled Solana transaction **messages** (Ed25519 over + * `messageBytes`). Branding/Kit supplies the bytes; Host may submit via + * Alchemy after merging signatures. + */ + async signTransaction( + transactions: readonly ISolanaSignTransactionInput[], + options?: SolanaCallOptions, + ): Promise { + if (transactions.length === 0) return []; + const results = await this.signer.signDigest( + transactions.map((tx) => ({ + digestData: digestDataForSolanaBytes(tx.messageBytes), + scheme: SOLANA_SIGN_SCHEME, + })), + { + credentialId: options?.credentialId ?? this.signer.getCredentialId(), + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + }, + ); + return results.map((r) => { + const signature = solanaSignatureFromHex(r.signature); + return { + signature, + signatureBytes: solanaSignatureToBytes(signature), + }; + }); + } +} diff --git a/packages/ows-signer-utils/src/solana/to-kit-signer.ts b/packages/ows-signer-utils/src/solana/to-kit-signer.ts new file mode 100644 index 0000000..18a70ba --- /dev/null +++ b/packages/ows-signer-utils/src/solana/to-kit-signer.ts @@ -0,0 +1,78 @@ +import { address, type Address } from "@solana/addresses"; +import type { + MessagePartialSigner, + SignatureDictionary, + TransactionPartialSigner, +} from "@solana/kit"; +import type { SignatureBytes } from "@solana/keys"; +import type { SolanaAccountAddress } from "@1shotapi/ows-types"; +import type { OWSSigner } from "../owssigner.js"; +import { solanaSignatureToBytes } from "./marshal.js"; +import type { SolanaCallOptions } from "./namespace.js"; + +export type ToKitPartialSignerOptions = SolanaCallOptions & { + /** Prefer this address over cache / `getAccountAddress` (no ceremony). */ + accountAddress?: SolanaAccountAddress; +}; + +export type OwsKitPartialSigner = MessagePartialSigner & TransactionPartialSigner; + +/** + * Build a Kit {@link MessagePartialSigner} + {@link TransactionPartialSigner} + * backed by the Signing Layer (`signer.solana`). + * + * Branding can plug this into Kit transaction planners without Host RPC. + */ +export async function toKitPartialSigner( + signer: OWSSigner, + options?: ToKitPartialSignerOptions, +): Promise { + const accountAddress = + options?.accountAddress ?? + signer.getCachedSolanaAddress() ?? + (await signer.solana.getAccountAddress({ + credentialId: options?.credentialId, + explanationHeader: options?.explanationHeader, + explanationText: options?.explanationText, + confirmButtonText: options?.confirmButtonText, + denyButtonText: options?.denyButtonText, + })); + + const kitAddress = address(accountAddress) as Address; + + const ceremonyOptions: SolanaCallOptions | undefined = options + ? { + credentialId: options.credentialId, + explanationHeader: options.explanationHeader, + explanationText: options.explanationText, + confirmButtonText: options.confirmButtonText, + denyButtonText: options.denyButtonText, + } + : undefined; + + const partialSigner: OwsKitPartialSigner = { + address: kitAddress, + async signMessages(messages): Promise { + const signatures = await signer.solana.signMessage( + messages.map((m) => m.content), + ceremonyOptions, + ); + return signatures.map((sig) => ({ + [kitAddress]: solanaSignatureToBytes(sig) as SignatureBytes, + })); + }, + async signTransactions(transactions): Promise { + const signed = await signer.solana.signTransaction( + transactions.map((tx) => ({ + messageBytes: new Uint8Array(tx.messageBytes), + })), + ceremonyOptions, + ); + return signed.map((result) => ({ + [kitAddress]: result.signatureBytes as SignatureBytes, + })); + }, + }; + + return partialSigner; +} diff --git a/packages/ows-signer-utils/src/webauthn/cose-to-spki.ts b/packages/ows-signer-utils/src/webauthn/cose-to-spki.ts new file mode 100644 index 0000000..d6415d4 --- /dev/null +++ b/packages/ows-signer-utils/src/webauthn/cose-to-spki.ts @@ -0,0 +1,241 @@ +import { + Base64UrlEncodedString, + ConversionUtils, + COSEPublicKey, + HexString, + SPKIPublicKey, +} from "@1shotapi/ows-types"; + +/** COSE key common / type params (IANA). */ +const COSEKEYS = { + kty: 1, + alg: 3, + crv: -1, + x: -2, + y: -3, +} as const; + +const COSEKTY = { + OKP: 1, + EC2: 2, +} as const; + +const COSECRV = { + P256: 1, + P384: 2, + P521: 3, + ED25519: 6, +} as const; + +/** Fixed SPKI SubjectPublicKeyInfo headers (OID + BIT STRING wrapper). */ +const SPKI_P256_PREFIX = ConversionUtils.hexToBytes( + HexString("0x3059301306072a8648ce3d020106082a8648ce3d030107034200"), +); +const SPKI_P384_PREFIX = ConversionUtils.hexToBytes( + HexString("0x3076301006072a8648ce3d020106052b81040022036200"), +); +const SPKI_ED25519_PREFIX = ConversionUtils.hexToBytes( + HexString("0x302a300506032b6570032100"), +); + +/** + * Convert a WebAuthn COSE credential public key to SPKI DER (base64url). + * Supports EC2 P-256 / P-384 and OKP Ed25519 (typical WebAuthn algs). + */ +export function COSEToSPKIPublicKey(cose: COSEPublicKey): SPKIPublicKey { + const bytes = ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(String(cose)), + ); + const map = decodeCoseMap(bytes); + const kty = map.get(COSEKEYS.kty); + if (typeof kty !== "number") { + throw new Error("COSE public key missing kty"); + } + + if (kty === COSEKTY.EC2) { + return ec2ToSpki(map); + } + if (kty === COSEKTY.OKP) { + return okpToSpki(map); + } + throw new Error(`unsupported COSE kty: ${kty}`); +} + +function ec2ToSpki(map: Map): SPKIPublicKey { + const crv = map.get(COSEKEYS.crv); + const x = ConversionUtils.asUint8Array(map.get(COSEKEYS.x), "COSE public key x"); + const y = ConversionUtils.asUint8Array(map.get(COSEKEYS.y), "COSE public key y"); + + let prefix: Uint8Array; + let coordLen: number; + if (crv === COSECRV.P256) { + prefix = SPKI_P256_PREFIX; + coordLen = 32; + } else if (crv === COSECRV.P384) { + prefix = SPKI_P384_PREFIX; + coordLen = 48; + } else { + throw new Error(`unsupported COSE EC2 curve: ${String(crv)}`); + } + if (x.length !== coordLen || y.length !== coordLen) { + throw new Error( + `COSE EC2 coordinate length mismatch (expected ${coordLen})`, + ); + } + + const spki = new Uint8Array(prefix.length + 1 + coordLen * 2); + spki.set(prefix, 0); + spki[prefix.length] = 0x04; // uncompressed point + spki.set(x, prefix.length + 1); + spki.set(y, prefix.length + 1 + coordLen); + return SPKIPublicKey(ConversionUtils.bytesToBase64Url(spki)); +} + +function okpToSpki(map: Map): SPKIPublicKey { + const crv = map.get(COSEKEYS.crv); + if (crv !== COSECRV.ED25519) { + throw new Error(`unsupported COSE OKP curve: ${String(crv)}`); + } + const x = ConversionUtils.asUint8Array(map.get(COSEKEYS.x), "COSE public key x"); + if (x.length !== 32) { + throw new Error("Ed25519 COSE x must be 32 bytes"); + } + const spki = new Uint8Array(SPKI_ED25519_PREFIX.length + x.length); + spki.set(SPKI_ED25519_PREFIX, 0); + spki.set(x, SPKI_ED25519_PREFIX.length); + return SPKIPublicKey(ConversionUtils.bytesToBase64Url(spki)); +} + +/** + * Decode a COSE key CBOR map (definite-length, integer keys). + */ +function decodeCoseMap(bytes: Uint8Array): Map { + const decoded = decodeCborFirst(bytes); + if (!(decoded instanceof Map)) { + throw new Error("COSE public key is not a CBOR map"); + } + return decoded as Map; +} + +function decodeCborFirst(buf: Uint8Array, offset = 0): unknown { + if (offset >= buf.length) throw new Error("cborTruncated"); + const initial = buf[offset]!; + const major = initial >> 5; + const additional = initial & 31; + let pos = offset + 1; + let argument = additional; + + if (additional === 24) { + argument = buf[pos]!; + pos += 1; + } else if (additional === 25) { + argument = (buf[pos]! << 8) | buf[pos + 1]!; + pos += 2; + } else if (additional === 26) { + argument = + ((buf[pos]! << 24) | + (buf[pos + 1]! << 16) | + (buf[pos + 2]! << 8) | + buf[pos + 3]!) >>> + 0; + pos += 4; + } else if (additional >= 27) { + throw new Error("cborLengthUnsupported"); + } + + if (major === 0) return argument; + if (major === 1) return -1 - argument; + if (major === 2) { + const end = pos + argument; + return buf.subarray(pos, end); + } + if (major === 3) { + const end = pos + argument; + return new TextDecoder().decode(buf.subarray(pos, end)); + } + if (major === 4) { + const arr: unknown[] = []; + let cursor = pos; + for (let i = 0; i < argument; i++) { + const [value, next] = decodeCborItem(buf, cursor); + arr.push(value); + cursor = next; + } + return arr; + } + if (major === 5) { + const map = new Map(); + let cursor = pos; + for (let i = 0; i < argument; i++) { + const [key, afterKey] = decodeCborItem(buf, cursor); + const [value, afterValue] = decodeCborItem(buf, afterKey); + map.set(key, value); + cursor = afterValue; + } + return map; + } + if (major === 7) { + if (additional === 20) return false; + if (additional === 21) return true; + if (additional === 22) return null; + throw new Error("cborSimpleUnsupported"); + } + throw new Error(`cborUnsupportedMajor:${major}`); +} + +function decodeCborItem( + buf: Uint8Array, + offset: number, +): [unknown, number] { + const value = decodeCborFirst(buf, offset); + const length = cborFirstItemLength(buf, offset); + return [value, offset + length]; +} + +function cborFirstItemLength(buf: Uint8Array, offset: number): number { + if (offset >= buf.length) throw new Error("cborTruncated"); + const initial = buf[offset]!; + const major = initial >> 5; + const additional = initial & 31; + let pos = offset + 1; + let argument = additional; + + if (additional === 24) { + argument = buf[pos]!; + pos += 1; + } else if (additional === 25) { + argument = (buf[pos]! << 8) | buf[pos + 1]!; + pos += 2; + } else if (additional === 26) { + argument = + ((buf[pos]! << 24) | + (buf[pos + 1]! << 16) | + (buf[pos + 2]! << 8) | + buf[pos + 3]!) >>> + 0; + pos += 4; + } else if (additional >= 27) { + throw new Error("cborLengthUnsupported"); + } + + if (major === 0 || major === 1) return pos - offset; + if (major === 2 || major === 3) return pos - offset + argument; + if (major === 7) { + if (additional < 24) return pos - offset; + if (additional === 25) return pos - offset + 2; + if (additional === 26) return pos - offset + 4; + if (additional === 27) return pos - offset + 8; + return pos - offset; + } + if (major === 6) { + return pos - offset + cborFirstItemLength(buf, pos); + } + if (major === 4 || major === 5) { + const valueCount = major === 5 ? argument * 2 : argument; + for (let i = 0; i < valueCount; i++) { + pos += cborFirstItemLength(buf, pos); + } + return pos - offset; + } + throw new Error("cborUnsupportedMajor"); +} diff --git a/packages/ows-signer-utils/test/bitcoin.test.ts b/packages/ows-signer-utils/test/bitcoin.test.ts new file mode 100644 index 0000000..0f77115 --- /dev/null +++ b/packages/ows-signer-utils/test/bitcoin.test.ts @@ -0,0 +1,94 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + BITCOIN_MAINNET_CHAIN_ID, + BITCOIN_TESTNET_CHAIN_ID, + BitcoinSegwitAccountAddress, + type SECP256K1PublicKey, +} from "@1shotapi/ows-types"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { + addressFromSecp256k1PublicKey, + compressSecp256k1PublicKey, +} from "../src/bitcoin/address.js"; +import { + finalizeBitcoinTransaction, + prepareBitcoinTransaction, + type IBitcoinUnsignedTransaction, +} from "../src/bitcoin/marshal.js"; + +describe("bitcoin signer utils", () => { + // Known secp256k1 key pair + const privKey = new Uint8Array(32); + privKey[31] = 1; // private key 1 + const uncompressedPub = secp256k1.getPublicKey(privKey, false); + const uncompressedPubHex = ("0x" + + Buffer.from(uncompressedPub).toString("hex")) as SECP256K1PublicKey; + + it("compresses 65-byte uncompressed public key correctly", () => { + const compressed = compressSecp256k1PublicKey(uncompressedPubHex); + assert.equal(compressed.length, 33); + const expected = secp256k1.getPublicKey(privKey, true); + assert.deepEqual(Array.from(compressed), Array.from(expected)); + }); + + it("derives mainnet and testnet Native SegWit (P2WPKH) addresses", () => { + const mainnetAddr = addressFromSecp256k1PublicKey( + uncompressedPubHex, + BITCOIN_MAINNET_CHAIN_ID, + ); + const testnetAddr = addressFromSecp256k1PublicKey( + uncompressedPubHex, + BITCOIN_TESTNET_CHAIN_ID, + ); + + assert.ok(mainnetAddr.startsWith("bc1q")); + assert.ok(testnetAddr.startsWith("tb1q")); + assert.equal( + mainnetAddr, + BitcoinSegwitAccountAddress("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + ); + assert.equal( + testnetAddr, + BitcoinSegwitAccountAddress("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"), + ); + }); + + it("prepares BIP-143 sighashes and finalizes a valid P2WPKH transaction", () => { + const mainnetAddr = addressFromSecp256k1PublicKey( + uncompressedPubHex, + BITCOIN_MAINNET_CHAIN_ID, + ); + + const unsignedTx: IBitcoinUnsignedTransaction = { + chainId: BITCOIN_MAINNET_CHAIN_ID, + inputs: [ + { + txid: "0000000000000000000000000000000000000000000000000000000000000001", + vout: 0, + valueSats: 100000n, + }, + ], + outputs: [ + { + address: mainnetAddr, + valueSats: 90000n, + }, + ], + }; + + const prepared = prepareBitcoinTransaction(unsignedTx, uncompressedPubHex); + assert.equal(prepared.sighashes.length, 1); + assert.equal(prepared.sighashes[0]!.length, 32); + + // Mock signing: compute low-S compact signature using secp256k1 + const sig = secp256k1.sign(prepared.sighashes[0]!, privKey, { lowS: true }); + const compactHex = ("0x" + + Buffer.from(sig).toString("hex")) as `0x${string}`; + + const finalized = finalizeBitcoinTransaction(prepared, [compactHex]); + assert.ok(finalized.rawHex.length > 0); + assert.equal(finalized.signatures.length, 1); + assert.ok(/^[0-9a-f]{64}$/.test(finalized.txid)); + }); +}); diff --git a/packages/ows-signer-utils/test/cose-to-spki.test.ts b/packages/ows-signer-utils/test/cose-to-spki.test.ts new file mode 100644 index 0000000..ad8b1f5 --- /dev/null +++ b/packages/ows-signer-utils/test/cose-to-spki.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { createPublicKey, generateKeyPairSync } from "node:crypto"; +import { describe, it } from "node:test"; +import { + Base64UrlEncodedString, + ConversionUtils, + COSEPublicKey, +} from "@1shotapi/ows-types"; +import { COSEToSPKIPublicKey } from "../src/webauthn/cose-to-spki.js"; + +const COSEKEYS = { + kty: 1, + alg: 3, + crv: -1, + x: -2, + y: -3, +} as const; + +describe("COSEToSPKIPublicKey", () => { + it("converts EC2 P-256 COSE to SPKI that Node can import", () => { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = publicKey.export({ format: "jwk" }); + assert.equal(jwk.kty, "EC"); + assert.ok(jwk.x && jwk.y); + + const x = ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(jwk.x), + ); + const y = ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(jwk.y), + ); + + const map = new Map(); + map.set(COSEKEYS.kty, 2); + map.set(COSEKEYS.alg, -7); + map.set(COSEKEYS.crv, 1); + map.set(COSEKEYS.x, x); + map.set(COSEKEYS.y, y); + const cose = COSEPublicKey( + ConversionUtils.bytesToBase64Url(encodeCborMap(map)), + ); + + const spki = COSEToSPKIPublicKey(cose); + const imported = createPublicKey({ + key: Buffer.from( + ConversionUtils.base64UrlToBytes(Base64UrlEncodedString(String(spki))), + ), + format: "der", + type: "spki", + }); + const roundTrip = imported.export({ format: "jwk" }); + assert.equal(roundTrip.x, jwk.x); + assert.equal(roundTrip.y, jwk.y); + assert.equal(roundTrip.crv, "P-256"); + }); +}); + +/** Minimal definite-length CBOR map encoder for integer keys / mixed values. */ +function encodeCborMap(map: Map): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeCborUint(5, map.size)); // major 5 = map + for (const [key, value] of map) { + parts.push(encodeCborInt(key)); + if (typeof value === "number") { + parts.push(encodeCborInt(value)); + } else { + parts.push(encodeCborBytes(value)); + } + } + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function encodeCborUint(major: number, value: number): Uint8Array { + if (value < 24) return new Uint8Array([(major << 5) | value]); + if (value < 256) return new Uint8Array([(major << 5) | 24, value]); + if (value < 65536) { + return new Uint8Array([(major << 5) | 25, value >> 8, value & 0xff]); + } + throw new Error("cbor uint too large"); +} + +function encodeCborInt(value: number): Uint8Array { + if (value >= 0) return encodeCborUint(0, value); + return encodeCborUint(1, -1 - value); +} + +function encodeCborBytes(bytes: Uint8Array): Uint8Array { + const header = encodeCborUint(2, bytes.length); + const out = new Uint8Array(header.length + bytes.length); + out.set(header, 0); + out.set(bytes, header.length); + return out; +} diff --git a/packages/ows-signer-utils/test/evm-sign-recover.test.ts b/packages/ows-signer-utils/test/evm-sign-recover.test.ts new file mode 100644 index 0000000..ffa261c --- /dev/null +++ b/packages/ows-signer-utils/test/evm-sign-recover.test.ts @@ -0,0 +1,175 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + hashMessage, + hashTypedData, + parseSignature, + recoverAddress, + recoverMessageAddress, + recoverTypedDataAddress, + serializeSignature, +} from "viem"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { + publicKeyToAddress, + recoverAuthorizationAddress, +} from "viem/utils"; +import { signWithScheme } from "../../ows-signer/src/crypto/sign.js"; +import { getPublicKey } from "../../ows-signer/src/crypto/vendor/noble-secp256k1.js"; +import { parse0xHex, to0xHex } from "../../ows-signer/src/hex.js"; +import { + digestForAuthorization, + digestForMessage, + digestForTypedData, + signedAuthorizationFromSignature, +} from "../src/evm/marshal.ts"; + +describe("evm recoverable signatures", () => { + it("signWithScheme matches viem privateKeyToAccount for personal_sign", async () => { + const privateKey = generatePrivateKey(); + const privBytes = parse0xHex(privateKey); + const account = privateKeyToAccount(privateKey); + const message = "ows recoverable v-byte check"; + + const digest = digestForMessage(message); + assert.equal(digest, hashMessage(message)); + + const owsSig = await signWithScheme( + "secp256k1-ecdsa-recoverable", + parse0xHex(digest), + privBytes, + new Uint8Array(32), + ); + const viemSig = await account.signMessage({ message }); + + const v = Number.parseInt(owsSig.slice(-2), 16); + assert.ok(v === 27 || v === 28, `ows v must be 27|28, got ${v}`); + + assert.equal( + await recoverMessageAddress({ message, signature: owsSig }), + account.address, + ); + assert.equal( + await recoverMessageAddress({ message, signature: viemSig }), + account.address, + ); + + // Same digest + key ⇒ same r/s (RFC6979); v may differ only if recovery encoding differed. + assert.equal(owsSig.slice(0, -2), viemSig.slice(0, -2)); + }); + + it("signWithScheme matches viem for EIP-712 typed data", async () => { + const privateKey = generatePrivateKey(); + const privBytes = parse0xHex(privateKey); + const account = privateKeyToAccount(privateKey); + + const typedData = { + domain: { + name: "DelegationManager", + version: "1", + chainId: 84532, + verifyingContract: + "0x0000000000000000000000000000000000000001" as const, + }, + types: { + Mail: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "contents", type: "string" }, + ], + }, + primaryType: "Mail" as const, + message: { + from: account.address, + to: "0x0000000000000000000000000000000000000002" as const, + contents: "delegation", + }, + }; + + const digest = digestForTypedData(typedData); + assert.equal(digest, hashTypedData(typedData)); + + const owsSig = await signWithScheme( + "secp256k1-ecdsa-recoverable", + parse0xHex(digest), + privBytes, + new Uint8Array(32), + ); + const viemSig = await account.signTypedData(typedData); + + const v = Number.parseInt(owsSig.slice(-2), 16); + assert.ok(v === 27 || v === 28, `ows v must be 27|28, got ${v}`); + + assert.equal( + await recoverTypedDataAddress({ ...typedData, signature: owsSig }), + account.address, + ); + assert.equal( + await recoverTypedDataAddress({ ...typedData, signature: viemSig }), + account.address, + ); + assert.equal( + await recoverAddress({ hash: digest, signature: owsSig }), + account.address, + ); + }); + + it("serializeSignature upgrades yParity 0/1 to v 27/28", () => { + const yParitySig = + (`0x${"11".repeat(32)}${"22".repeat(32)}01`) as `0x${string}`; + const canonical = serializeSignature(parseSignature(yParitySig)); + assert.equal(canonical.slice(-2), "1c"); + }); + + it("recovered address matches noble public key", async () => { + const privBytes = parse0xHex( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ); + const pub = getPublicKey(privBytes, false); + const expected = publicKeyToAddress(to0xHex(pub) as `0x${string}`); + const digest = hashMessage("hello ows"); + const sig = await signWithScheme( + "secp256k1-ecdsa-recoverable", + parse0xHex(digest), + privBytes, + new Uint8Array(32), + ); + assert.equal( + await recoverAddress({ hash: digest, signature: sig }), + expected, + ); + }); + + it("EIP-7702 authorization recovers to signer and exposes yParity", async () => { + const privateKey = generatePrivateKey(); + const privBytes = parse0xHex(privateKey); + const account = privateKeyToAccount(privateKey); + const authorization = { + chainId: 84532, + contractAddress: + "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B" as const, + nonce: 7, + }; + + const digest = digestForAuthorization(authorization); + const owsSig = await signWithScheme( + "secp256k1-ecdsa-recoverable", + parse0xHex(digest), + privBytes, + new Uint8Array(32), + ); + const signed = signedAuthorizationFromSignature(authorization, owsSig); + assert.ok(signed.yParity === 0 || signed.yParity === 1); + + const recovered = await recoverAuthorizationAddress({ + authorization: signed, + }); + assert.equal(recovered, account.address); + + const viemSigned = await account.signAuthorization(authorization); + assert.equal( + await recoverAuthorizationAddress({ authorization: viemSigned }), + account.address, + ); + }); +}); diff --git a/packages/ows-signer-utils/test/iframe-overlay.test.ts b/packages/ows-signer-utils/test/iframe-overlay.test.ts new file mode 100644 index 0000000..49c4530 --- /dev/null +++ b/packages/ows-signer-utils/test/iframe-overlay.test.ts @@ -0,0 +1,132 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { overlaySignerIframe, showSignerCeremonyPanel } from "../src/iframe.ts"; + +function createStyleableElement(tag: string): HTMLElement { + const styleStore = new Map(); + const style = { + length: 0, + item(index: number): string { + return [...styleStore.keys()][index] ?? ""; + }, + getPropertyValue(name: string): string { + return styleStore.get(name) ?? ""; + }, + setProperty(name: string, value: string): void { + if (!styleStore.has(name)) { + Object.defineProperty(style, "length", { + value: styleStore.size + 1, + writable: true, + configurable: true, + }); + } + styleStore.set(name, value); + (style as unknown as Record)[name] = value; + }, + }; + + const el = { + style, + tagName: tag.toUpperCase(), + parentElement: null as HTMLElement | null, + contentWindow: { focus() {} }, + focus() {}, + removeAttribute(name: string) { + if (name === "style") { + styleStore.clear(); + Object.defineProperty(style, "length", { + value: 0, + writable: true, + configurable: true, + }); + } + }, + getBoundingClientRect() { + return { + top: 40, + left: 80, + width: 320, + height: 120, + bottom: 160, + right: 400, + x: 80, + y: 40, + toJSON() { + return {}; + }, + }; + }, + }; + + return el as unknown as HTMLElement; +} + +describe("overlaySignerIframe", () => { + it("positions home container over the slot and restores styles", () => { + const home = createStyleableElement("div"); + home.style.setProperty("opacity", "0.5"); + + const iframe = createStyleableElement("iframe") as unknown as HTMLIFrameElement; + (iframe as unknown as { parentElement: HTMLElement }).parentElement = home; + + const slot = createStyleableElement("div"); + + const restore = overlaySignerIframe(iframe, slot, { homeContainer: home }); + + assert.equal(home.style.getPropertyValue("position"), "fixed"); + assert.equal(home.style.getPropertyValue("top"), "40px"); + assert.equal(home.style.getPropertyValue("left"), "80px"); + assert.equal(home.style.getPropertyValue("width"), "320px"); + assert.equal(home.style.getPropertyValue("height"), "120px"); + assert.equal(iframe.style.getPropertyValue("width"), "100%"); + assert.equal(iframe.style.getPropertyValue("height"), "100%"); + + restore(); + + assert.equal(home.style.getPropertyValue("opacity"), "0.5"); + assert.equal(home.style.getPropertyValue("position"), ""); + assert.equal(iframe.style.getPropertyValue("width"), ""); + }); + + it("throws when homeContainer cannot be resolved", () => { + const iframe = createStyleableElement("iframe") as unknown as HTMLIFrameElement; + const slot = createStyleableElement("div"); + + assert.throws( + () => overlaySignerIframe(iframe, slot), + /homeContainer/, + ); + }); +}); + +describe("showSignerCeremonyPanel", () => { + it("shows a bottom full-width transparent strip and restores styles", () => { + const home = createStyleableElement("div"); + home.style.setProperty("opacity", "0"); + + const iframe = createStyleableElement("iframe") as unknown as HTMLIFrameElement; + (iframe as unknown as { parentElement: HTMLElement }).parentElement = home; + + const restore = showSignerCeremonyPanel(iframe); + + assert.equal(home.style.getPropertyValue("opacity"), "1"); + assert.equal(home.style.getPropertyValue("position"), "fixed"); + assert.equal(home.style.getPropertyValue("pointer-events"), "auto"); + assert.equal(home.style.getPropertyValue("left"), "0"); + assert.equal(home.style.getPropertyValue("right"), "0"); + assert.equal(home.style.getPropertyValue("bottom"), "0"); + assert.equal(home.style.getPropertyValue("top"), "auto"); + assert.equal(home.style.getPropertyValue("width"), "100%"); + assert.equal(home.style.getPropertyValue("background"), "transparent"); + assert.equal(home.style.getPropertyValue("box-shadow"), "none"); + assert.equal(iframe.style.getPropertyValue("opacity"), "1"); + assert.equal(home.style.getPropertyValue("height"), "50dvh"); + assert.equal(home.style.getPropertyValue("overflow"), "hidden"); + assert.equal(iframe.style.getPropertyValue("height"), "100%"); + + restore(); + + assert.equal(home.style.getPropertyValue("opacity"), "0"); + assert.equal(iframe.style.getPropertyValue("opacity"), ""); + }); +}); diff --git a/packages/ows-signer-utils/test/marshal.test.ts b/packages/ows-signer-utils/test/marshal.test.ts new file mode 100644 index 0000000..e1b29de --- /dev/null +++ b/packages/ows-signer-utils/test/marshal.test.ts @@ -0,0 +1,113 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + hashMessage, + hashTypedData, + keccak256, + serializeTransaction, +} from "viem"; +import { + digestForAuthorization, + digestForMessage, + digestForTransaction, + digestForTypedData, + serializeSignedTransaction, + signedAuthorizationFromSignature, +} from "../src/evm/marshal.ts"; + +describe("evm/marshal", () => { + it("digestForMessage matches viem hashMessage", () => { + const message = "hello ows"; + assert.equal(digestForMessage(message), hashMessage(message)); + }); + + it("digestForTypedData matches viem hashTypedData", () => { + const typedData = { + domain: { + name: "Test", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + types: { + Mail: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "contents", type: "string" }, + ], + }, + primaryType: "Mail" as const, + message: { + from: "0x0000000000000000000000000000000000000001", + to: "0x0000000000000000000000000000000000000002", + contents: "hi", + }, + }; + assert.equal(digestForTypedData(typedData), hashTypedData(typedData)); + }); + + it("digestForTransaction hashes serialized unsigned tx", () => { + const transaction = { + type: "legacy", + chainId: 1, + to: "0x0000000000000000000000000000000000000001", + value: 0n, + gas: 21_000n, + gasPrice: 1_000_000_000n, + nonce: 0, + } as const; + const serialized = serializeTransaction(transaction); + assert.equal(digestForTransaction(transaction), keccak256(serialized)); + }); + + it("serializeSignedTransaction produces signed hex", () => { + const transaction = { + type: "legacy", + chainId: 1, + to: "0x0000000000000000000000000000000000000001", + value: 0n, + gas: 21_000n, + gasPrice: 1_000_000_000n, + nonce: 0, + } as const; + const signature = + `0x${"aa".repeat(32)}${"bb".repeat(32)}1c` as const; + const signed = serializeSignedTransaction(transaction, signature); + assert.match(signed, /^0x[0-9a-f]+$/i); + assert.notEqual(signed, serializeTransaction(transaction)); + }); + + it("signedAuthorizationFromSignature splits r/s/yParity", () => { + const authorization = { + chainId: 1, + address: "0x0000000000000000000000000000000000000001", + nonce: 0, + } as const; + const signature = + `0x${"cc".repeat(32)}${"dd".repeat(32)}1b` as const; + const digest = digestForAuthorization(authorization); + assert.match(digest, /^0x[0-9a-f]{64}$/i); + + const signed = signedAuthorizationFromSignature(authorization, signature); + assert.equal(signed.chainId, authorization.chainId); + assert.equal(signed.address, authorization.address); + assert.equal(signed.nonce, authorization.nonce); + assert.match(signed.r, /^0x/); + assert.match(signed.s, /^0x/); + // Must expose yParity for relayer JSON (not only v), even when sig ends in 0x1b. + assert.equal(signed.yParity, 0); + assert.equal("v" in signed, false); + }); + + it("signedAuthorizationFromSignature maps v=28 to yParity=1", () => { + const authorization = { + chainId: 84532, + address: "0x0000000000000000000000000000000000000001", + nonce: 3, + } as const; + const signature = + `0x${"11".repeat(32)}${"22".repeat(32)}1c` as const; + const signed = signedAuthorizationFromSignature(authorization, signature); + assert.equal(signed.yParity, 1); + }); +}); diff --git a/packages/ows-signer-utils/test/owssigner.test.ts b/packages/ows-signer-utils/test/owssigner.test.ts new file mode 100644 index 0000000..e36312d --- /dev/null +++ b/packages/ows-signer-utils/test/owssigner.test.ts @@ -0,0 +1,380 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { hashMessage } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { OWSSigner } from "../src/owssigner.ts"; +import { API_VERSION, CredentialId } from "@1shotapi/ows-types"; + +const SIGNER_ORIGIN = "https://signer.example"; +const SIGNER_URL = `${SIGNER_ORIGIN}/signer.html`; + +/** secp256k1 generator point G (uncompressed). */ +const SECP_KEY_A = + ("0x04" + + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8") as `0x${string}`; + +/** secp256k1 private key 0x02 → distinct address from G. */ +const ACCOUNT_B = privateKeyToAccount( + ("0x" + "02".repeat(32)) as `0x${string}`, +); +const SECP_KEY_B = ACCOUNT_B.publicKey; + +const ED25519_KEY_A = ("0x" + "55".repeat(32)) as `0x${string}`; +const ED25519_KEY_B = ("0x" + "66".repeat(32)) as `0x${string}`; + +type MessageListener = (event: MessageEvent) => void; + +type PublicKeyPayload = { + cosePublicKey: null; + secp256k1PublicKey: `0x${string}`; + ed25519PublicKey: `0x${string}`; + credentialId?: string; +}; + +type SetupOptions = { + /** When set, getPublicKey does not auto-reply — tests drive events via reply(). */ + holdGetPublicKey?: boolean; + publicKeyQueue?: PublicKeyPayload[]; +}; + +function setupBrowserMocks(options: SetupOptions = {}) { + const listeners = new Set(); + const contentWindow = {} as Window; + const publicKeyQueue = [...(options.publicKeyQueue ?? [])]; + let lastCorrelationId = "0"; + + const iframe = { + contentWindow, + style: {} as CSSStyleDeclaration, + remove() {}, + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) { + if (type === "load") { + const once = typeof options === "object" && options.once; + const handler = + typeof listener === "function" ? listener : listener.handleEvent; + queueMicrotask(() => { + handler.call(iframe, new Event("load")); + if (once) { + iframe.removeEventListener(type, listener); + } + }); + } + }, + removeEventListener() {}, + } as unknown as HTMLIFrameElement; + + globalThis.document = { + createElement(tag: string) { + assert.equal(tag, "iframe"); + return iframe; + }, + } as unknown as Document; + + globalThis.window = { + location: { origin: SIGNER_ORIGIN, href: SIGNER_URL }, + addEventListener(type: string, listener: MessageListener) { + if (type === "message") listeners.add(listener); + }, + removeEventListener(type: string, listener: MessageListener) { + if (type === "message") listeners.delete(listener); + }, + } as unknown as Window; + + function reply( + event: string, + data: Record, + correlationId: string = lastCorrelationId, + ) { + const payload = { + v: API_VERSION, + kind: "event" as const, + event, + correlationId, + data, + }; + queueMicrotask(() => { + for (const listener of listeners) { + listener({ + origin: SIGNER_ORIGIN, + source: contentWindow, + data: payload, + } as MessageEvent); + } + }); + } + + contentWindow.postMessage = (message: { + method?: string; + correlationId?: string; + kind?: string; + }) => { + if (message.kind === "cancel") return; + + const id = message.correlationId ?? "0"; + lastCorrelationId = id; + const method = message.method; + + if (method === "getVersion") { + reply( + "Version", + { + apiVersion: 1, + signerVersion: "test-signer", + recoverySessionActive: false, + }, + id, + ); + return; + } + + if (method === "clearRecoverySession") { + reply("RecoverySessionCleared", {}, id); + return; + } + + if (method === "signDigest") { + // yParity=1 (not 27/28) — EvmSigner must canonicalize for ecrecover. + reply( + "DigestSigned", + { + results: [ + { + digest: "0x" + "22".repeat(32), + scheme: "secp256k1-ecdsa-recoverable", + signature: ("0x" + "33".repeat(64) + "01") as `0x${string}`, + credentialId: "cred-1", + }, + ], + }, + id, + ); + return; + } + + if (method === "executeBatch") { + reply( + "BatchExecuted", + { + results: [ + { + digest: "0x" + "22".repeat(32), + scheme: "secp256k1-ecdsa-recoverable", + signature: ("0x" + "33".repeat(64) + "01") as `0x${string}`, + credentialId: "cred-1", + }, + ], + ciphertexts: ["ows-aes1:0xdead"], + }, + id, + ); + return; + } + + if (method === "getPublicKey") { + if (options.holdGetPublicKey) { + return; + } + const next = publicKeyQueue.shift() ?? { + cosePublicKey: null, + secp256k1PublicKey: SECP_KEY_A, + ed25519PublicKey: ED25519_KEY_A, + }; + reply("KeyDerived", { + secp256k1PublicKey: next.secp256k1PublicKey, + ed25519PublicKey: next.ed25519PublicKey, + }, id); + reply("PublicKey", next, id); + } + }; + + const container = { + appendChild(node: Node) { + assert.equal(node, iframe); + }, + } as unknown as HTMLElement; + + return { container, iframe, reply, getLastCorrelationId: () => lastCorrelationId }; +} + +describe("OWSSigner", () => { + afterEach(() => { + // @ts-expect-error test cleanup + delete globalThis.window; + // @ts-expect-error test cleanup + delete globalThis.document; + }); + + it("creates via factory and calls getVersion", async () => { + const { container } = setupBrowserMocks(); + const signer = await OWSSigner.create(container, SIGNER_URL); + + const version = await signer.getVersion(); + assert.equal(version.signerVersion, "test-signer"); + assert.equal(version.recoverySessionActive, false); + + signer.destroy(); + }); + + it("signDigest and evm.signMessage delegate to RPC", async () => { + const { container } = setupBrowserMocks(); + const signer = await OWSSigner.create(container, SIGNER_URL, { + credentialId: CredentialId("cred-1"), + }); + + const digestResults = await signer.signDigest([ + { digestData: hashMessage("hi") }, + ]); + assert.equal(digestResults.length, 1); + assert.equal(digestResults[0]!.scheme, "secp256k1-ecdsa-recoverable"); + assert.equal(digestResults[0]!.signature.slice(-2), "01"); + + const [signature] = await signer.evm.signMessage(["hi"]); + assert.equal(signature!.slice(-2), "1c"); + + signer.destroy(); + }); + + it("executeBatch delegates to RPC", async () => { + const { container } = setupBrowserMocks(); + const signer = await OWSSigner.create(container, SIGNER_URL, { + credentialId: CredentialId("cred-1"), + }); + + const batch = await signer.executeBatch({ + digests: [ + { + digestData: hashMessage("hi"), + scheme: "secp256k1-ecdsa-recoverable", + }, + ], + plaintexts: ["secret"], + }); + assert.equal(batch.results?.length, 1); + assert.equal(batch.ciphertexts?.length, 1); + + signer.destroy(); + }); + + it("caches EVM, Solana, and Bitcoin addresses from getPublicKey", async () => { + const { container } = setupBrowserMocks(); + const signer = await OWSSigner.create(container, SIGNER_URL, { + credentialId: CredentialId("cred-1"), + }); + + const evm = await signer.evm.getAccountAddress(); + assert.match(evm, /^0x[0-9a-f]{40}$/i); + assert.equal(signer.getCachedAddress(), evm); + + const solana = await signer.solana.getAccountAddress(); + assert.equal(signer.getCachedSolanaAddress(), solana); + + const btcMainnet = await signer.bitcoin.getAccountAddress(); + assert.ok(btcMainnet.startsWith("bc1q")); + assert.equal(signer.getCachedBitcoinSegwitAddress(), btcMainnet); + + signer.destroy(); + }); + + it("overwrites address caches when getPublicKey returns different keys", async () => { + const { container } = setupBrowserMocks({ + publicKeyQueue: [ + { + cosePublicKey: null, + secp256k1PublicKey: SECP_KEY_A, + ed25519PublicKey: ED25519_KEY_A, + credentialId: "cred-a", + }, + { + cosePublicKey: null, + secp256k1PublicKey: SECP_KEY_B, + ed25519PublicKey: ED25519_KEY_B, + credentialId: "cred-b", + }, + ], + }); + const signer = await OWSSigner.create(container, SIGNER_URL); + + const first = await signer.getPublicKey({ discoverable: true }); + const evmA = signer.getCachedAddress(); + const solanaA = signer.getCachedSolanaAddress(); + assert.ok(evmA); + assert.ok(solanaA); + assert.equal(first.credentialId, "cred-a"); + + const second = await signer.getPublicKey({ discoverable: true }); + const evmB = signer.getCachedAddress(); + const solanaB = signer.getCachedSolanaAddress(); + assert.equal(second.credentialId, "cred-b"); + assert.notEqual(evmB, evmA); + assert.notEqual(solanaB, solanaA); + assert.equal( + (await signer.evm.getAccountAddress()).toLowerCase(), + ACCOUNT_B.address.toLowerCase(), + ); + + signer.destroy(); + }); + + it("clearSession rejects pending RPCs so late KeyDerived cannot refill cache", async () => { + const { container, reply, getLastCorrelationId } = setupBrowserMocks({ + holdGetPublicKey: true, + }); + const signer = await OWSSigner.create(container, SIGNER_URL, { + credentialId: CredentialId("cred-old"), + }); + + const pending = signer.getPublicKey({ credentialId: CredentialId("cred-old") }); + const oldId = getLastCorrelationId(); + + await signer.clearSession(); + await assert.rejects(pending, /session cleared/); + + // Late KeyDerived for the abandoned correlation id must be ignored. + reply( + "KeyDerived", + { + secp256k1PublicKey: SECP_KEY_A, + ed25519PublicKey: ED25519_KEY_A, + }, + oldId, + ); + await new Promise((r) => queueMicrotask(r)); + assert.equal(signer.getCachedAddress(), undefined); + + // Fresh login with a different passkey wins (still held — drive events). + const login = signer.getPublicKey({ discoverable: true }); + const newId = getLastCorrelationId(); + reply( + "KeyDerived", + { + secp256k1PublicKey: SECP_KEY_B, + ed25519PublicKey: ED25519_KEY_B, + }, + newId, + ); + reply( + "PublicKey", + { + cosePublicKey: null, + secp256k1PublicKey: SECP_KEY_B, + ed25519PublicKey: ED25519_KEY_B, + credentialId: "cred-b", + }, + newId, + ); + await login; + + assert.equal( + signer.getCachedAddress()?.toLowerCase(), + ACCOUNT_B.address.toLowerCase(), + ); + assert.equal(signer.getCredentialId(), "cred-b"); + + signer.destroy(); + }); +}); diff --git a/packages/ows-signer-utils/test/rpc-client.test.ts b/packages/ows-signer-utils/test/rpc-client.test.ts new file mode 100644 index 0000000..fe3b556 --- /dev/null +++ b/packages/ows-signer-utils/test/rpc-client.test.ts @@ -0,0 +1,283 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + OwsInvalidRequestError, + OwsNotAllowedError, + OwsSignDeniedError, +} from "../src/errors.ts"; +import { + SignerRpcClient, + isSignerEventMessage, +} from "../src/rpc/client.ts"; +import { API_VERSION } from "@1shotapi/ows-types"; + +const SIGNER_ORIGIN = "https://signer.example"; + +type MessageListener = (event: MessageEvent) => void; + +function setupDomMocks() { + const listeners = new Set(); + const posted: Array<{ message: unknown; targetOrigin: string }> = []; + const contentWindow = {} as Window; + + const iframe = { + contentWindow, + } as HTMLIFrameElement; + + globalThis.window = { + addEventListener(type: string, listener: MessageListener) { + if (type === "message") listeners.add(listener); + }, + removeEventListener(type: string, listener: MessageListener) { + if (type === "message") listeners.delete(listener); + }, + } as unknown as Window; + + contentWindow.postMessage = ( + message: unknown, + targetOrigin: string, + ): void => { + posted.push({ message, targetOrigin }); + }; + + function dispatch( + event: string, + data: Record, + correlationId = "1", + origin = SIGNER_ORIGIN, + source: Window = contentWindow, + ) { + const payload = { + v: API_VERSION, + kind: "event" as const, + event, + correlationId, + data, + }; + for (const listener of listeners) { + listener({ + origin, + source, + data: payload, + } as MessageEvent); + } + } + + function emit(event: string, data: Record, correlationId = "1") { + dispatch(event, data, correlationId); + } + + return { iframe, posted, emit, dispatch, contentWindow }; +} + +describe("SignerRpcClient", () => { + afterEach(() => { + // @ts-expect-error test cleanup + delete globalThis.window; + }); + + it("posts correlated requests to the signer origin", async () => { + const { iframe, posted, emit } = setupDomMocks(); + const client = new SignerRpcClient(iframe, SIGNER_ORIGIN); + + const promise = client.request("getVersion", undefined, { + terminalEvent: "Version", + }); + + // The client sends a cancel message before each request, then the request. + assert.equal(posted.length, 2); + assert.deepEqual(posted[0]?.message, { + v: API_VERSION, + kind: "cancel", + }); + assert.equal(posted[1]?.targetOrigin, SIGNER_ORIGIN); + assert.deepEqual(posted[1]?.message, { + v: 1, + kind: "request", + method: "getVersion", + correlationId: "1", + params: undefined, + }); + + emit("Version", { + apiVersion: 1, + signerVersion: "0.1.0", + recoverySessionActive: false, + }); + + const result = await promise; + assert.equal(result.signerVersion, "0.1.0"); + client.destroy(); + }); + + it("rejects NotAllowed, SignDenied, and InvalidRequest terminal errors", async () => { + const { iframe, emit } = setupDomMocks(); + const client = new SignerRpcClient(iframe, SIGNER_ORIGIN); + + const notAllowed = client.request("signDigest", { digestData: "0x" + "11".repeat(32) }, { + terminalEvent: "DigestSigned", + }); + emit("NotAllowed", { reason: "userCancelled" }); + await assert.rejects(notAllowed, OwsNotAllowedError); + + const signDenied = client.request("signDigest", { digests: [] }, { + terminalEvent: "DigestSigned", + }); + emit("SignDenied", { reason: "signDenied" }, "2"); + await assert.rejects(signDenied, OwsSignDeniedError); + + const invalid = client.request("getVersion", undefined, { + terminalEvent: "Version", + }); + emit("InvalidRequest", { reason: "badParams" }, "3"); + await assert.rejects(invalid, OwsInvalidRequestError); + + client.destroy(); + }); + + it("stays pending until a late terminal event (no RPC timeout)", async () => { + const { iframe, emit } = setupDomMocks(); + const client = new SignerRpcClient(iframe, SIGNER_ORIGIN); + + const promise = client.request("getVersion", undefined, { + terminalEvent: "Version", + }); + let settled = false; + void promise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(settled, false); + + emit("Version", { + apiVersion: 1, + signerVersion: "0.1.0", + recoverySessionActive: false, + }); + const result = await promise; + assert.equal(result.signerVersion, "0.1.0"); + client.destroy(); + }); + + it("merges alsoWaitFor events after terminal PublicKey", async () => { + const { iframe, emit } = setupDomMocks(); + const client = new SignerRpcClient(iframe, SIGNER_ORIGIN); + + const promise = client.request("getPublicKey", { challenge: "0x" + "aa".repeat(32) }, { + terminalEvent: "PublicKey", + alsoWaitFor: ["ChallengeSigned"], + }); + + emit("PublicKey", { + cosePublicKey: null, + secp256k1PublicKey: "0x" + "04".repeat(32), + ed25519PublicKey: "0x" + "05".repeat(32), + }); + + emit("ChallengeSigned", { + challenge: "0x" + "aa".repeat(32), + assertion: { + authenticatorData: "ad", + clientDataJSON: "cdj", + signature: "sig", + credentialId: "cid", + }, + }); + + const result = await promise; + assert.deepEqual(result.assertion, { + authenticatorData: "ad", + clientDataJSON: "cdj", + signature: "sig", + credentialId: "cid", + }); + assert.match(String(result.secp256k1PublicKey), /^0x/); + + client.destroy(); + }); + + it("rejectAllPending rejects in-flight requests and ignores later events", async () => { + const { iframe, emit, posted } = setupDomMocks(); + const client = new SignerRpcClient(iframe, SIGNER_ORIGIN); + + const promise = client.request("getPublicKey", undefined, { + terminalEvent: "PublicKey", + onIntermediate: () => { + assert.fail("late KeyDerived must not call onIntermediate after reject"); + }, + }); + + client.rejectAllPending("session cleared"); + await assert.rejects(promise, /session cleared/); + + const cancel = posted.find( + (entry) => + entry.message && + typeof entry.message === "object" && + (entry.message as { kind?: string }).kind === "cancel", + ); + assert.ok(cancel, "rejectAllPending should post cancel"); + + emit("KeyDerived", { + secp256k1PublicKey: "0x" + "04".repeat(65), + ed25519PublicKey: "0x" + "55".repeat(32), + }); + emit("PublicKey", { + cosePublicKey: null, + secp256k1PublicKey: "0x" + "04".repeat(65), + ed25519PublicKey: "0x" + "55".repeat(32), + }); + + client.destroy(); + }); + + it("ignores messages from wrong origin", async () => { + const { iframe, dispatch, emit } = setupDomMocks(); + const client = new SignerRpcClient(iframe, SIGNER_ORIGIN); + + const promise = client.request("getVersion", undefined, { + terminalEvent: "Version", + }); + + dispatch( + "Version", + { + apiVersion: 1, + signerVersion: "evil", + recoverySessionActive: false, + }, + "1", + "https://evil.example", + ); + + emit("Version", { + apiVersion: 1, + signerVersion: "good", + recoverySessionActive: false, + }); + + const result = await promise; + assert.equal(result.signerVersion, "good"); + client.destroy(); + }); +}); + +describe("isSignerEventMessage", () => { + it("validates event envelope shape", () => { + assert.equal( + isSignerEventMessage({ + v: 1, + kind: "event", + event: "Version", + data: {}, + }), + true, + ); + assert.equal(isSignerEventMessage({ kind: "event" }), false); + }); +}); diff --git a/packages/ows-signer-utils/test/scaffold.test.ts b/packages/ows-signer-utils/test/scaffold.test.ts new file mode 100644 index 0000000..e0832a2 --- /dev/null +++ b/packages/ows-signer-utils/test/scaffold.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { OWSSigner } from "../src/index.ts"; + +describe("@1shotapi/ows-signer-utils exports", () => { + it("exports OWSSigner factory class", () => { + assert.equal(typeof OWSSigner.create, "function"); + }); +}); diff --git a/packages/ows-signer-utils/test/sign-helper.test.ts b/packages/ows-signer-utils/test/sign-helper.test.ts new file mode 100644 index 0000000..e88a225 --- /dev/null +++ b/packages/ows-signer-utils/test/sign-helper.test.ts @@ -0,0 +1,441 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + EVMAccountAddress, + EVMChainId, + EVMSignatureHex, + EVMTransactionHash, + HexString, + OwsInvalidParamsError, + OwsUserRejectedError, +} from "@1shotapi/ows-types"; +import { + SignHelper, + parseTypedData, + type SignHelperOptions, + type SignHelperSigner, + type SignHelperWallet, +} from "../src/eip1193/sign-helper.ts"; + +function createMocks(options?: { + cachedAddress?: EVMAccountAddress | null; + chainId?: EVMChainId; +}) { + const calls: string[] = []; + let hideCount = 0; + const chainId = options?.chainId ?? EVMChainId("0xaa36a7"); + let cached = options?.cachedAddress ?? null; + + const wallet: SignHelperWallet = { + async requestDisplay() { + calls.push("requestDisplay"); + return { + async hide() { + hideCount += 1; + calls.push("hide"); + }, + }; + }, + }; + + const signer: SignHelperSigner = { + getCachedAddress: () => cached, + evm: { + async getAccountAddress() { + calls.push("getAccountAddress"); + const address = EVMAccountAddress( + "0x1111111111111111111111111111111111111111", + ); + cached = address; + return address; + }, + async signMessage(messages) { + calls.push(`signMessage:${messages[0]}`); + return [EVMSignatureHex("0xsig")]; + }, + async signTypedData(typedDataList) { + calls.push(`signTypedData:${typedDataList[0]?.primaryType}`); + return [EVMSignatureHex("0xtyped")]; + }, + async signTransaction(transactions) { + calls.push(`signTransaction:${transactions[0]?.chainId}`); + return [HexString("0xsignedraw")]; + }, + }, + }; + + return { + wallet, + signer, + chainId, + calls, + getHideCount: () => hideCount, + setCached: (address: EVMAccountAddress | null) => { + cached = address; + }, + }; +} + +function brandingSignOptions( + signer: SignHelperSigner, + calls: string[], + overrides?: Partial, +): Pick< + SignHelperOptions, + | "approveAndSignPersonalMessage" + | "approveAndSignTypedData" + | "approveAndSignTransaction" +> { + return { + approveAndSignPersonalMessage: async (request) => { + calls.push(`consent:${request.address}:${request.message}`); + const [signature] = await signer.evm.signMessage([request.message]); + return signature!; + }, + approveAndSignTypedData: async (request) => { + calls.push(`typedConsent:${request.typedData.primaryType}`); + const [signature] = await signer.evm.signTypedData([ + request.typedData as never, + ]); + return signature!; + }, + approveAndSignTransaction: async () => TX_HASH, + ...overrides, + }; +} + +const sampleTypedData = { + types: { + EIP712Domain: [{ name: "name", type: "string" }], + Mail: [{ name: "contents", type: "string" }], + }, + primaryType: "Mail", + domain: { name: "Test" }, + message: { contents: "hi" }, +}; + +const account = EVMAccountAddress( + "0x1111111111111111111111111111111111111111", +); + +const TX_HASH = EVMTransactionHash( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +); + +describe("parseTypedData", () => { + it("parses JSON strings and passes objects through", () => { + assert.deepEqual(parseTypedData(sampleTypedData), sampleTypedData); + assert.deepEqual( + parseTypedData(JSON.stringify(sampleTypedData)), + sampleTypedData, + ); + }); +}); + +describe("SignHelper", () => { + it("personal_sign: display → branding consent+sign → hide", async () => { + const { wallet, signer, chainId, calls, getHideCount } = createMocks(); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls), + }); + + const result = await helper.handlers.personal_sign(["hello", account]); + + assert.equal(result, "0xsig"); + assert.deepEqual(calls, [ + "requestDisplay", + `consent:${account}:hello`, + "signMessage:hello", + "hide", + ]); + assert.equal(getHideCount(), 1); + }); + + it("personal_sign: onAuthenticated runs after display release", async () => { + const { signer, chainId, calls } = createMocks(); + let releaseCount = 0; + const walletWithRelease: SignHelperWallet = { + async requestDisplay() { + calls.push("requestDisplay"); + return { + release() { + releaseCount += 1; + calls.push("release"); + }, + async hide() { + calls.push("hide"); + }, + }; + }, + }; + + const helper = new SignHelper(signer, walletWithRelease, { + getChainId: () => chainId, + onAuthenticated: async () => { + calls.push("onAuthenticated"); + }, + ...brandingSignOptions(signer, calls), + }); + + await helper.handlers.personal_sign(["hello", account]); + + assert.deepEqual(calls, [ + "requestDisplay", + `consent:${account}:hello`, + "signMessage:hello", + "release", + "onAuthenticated", + ]); + assert.equal(releaseCount, 1); + }); + + it("personal_sign: rejects without signing when branding throws", async () => { + const { wallet, signer, chainId, calls, getHideCount } = createMocks(); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls, { + approveAndSignPersonalMessage: async () => { + throw new OwsUserRejectedError("User rejected the signing request"); + }, + }), + }); + + await assert.rejects( + () => helper.handlers.personal_sign(["hello", account]), + (error: unknown) => error instanceof OwsUserRejectedError, + ); + + assert.deepEqual(calls, ["requestDisplay", "hide"]); + assert.equal(getHideCount(), 1); + }); + + it("typed data: parses JSON string and signs after consent", async () => { + const { wallet, signer, chainId, calls } = createMocks(); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls), + }); + + const result = await helper.handlers.eth_signTypedData_v4([ + account, + JSON.stringify(sampleTypedData), + ]); + + assert.equal(result, "0xtyped"); + assert.ok(calls.includes("typedConsent:Mail")); + assert.ok(calls.includes("signTypedData:Mail")); + assert.ok(calls.includes("hide")); + }); + + it("hides display when branding approve throws", async () => { + const { wallet, signer, chainId, calls, getHideCount } = createMocks(); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls, { + approveAndSignPersonalMessage: async () => { + throw new Error("unlock failed"); + }, + }), + }); + + await assert.rejects( + () => helper.handlers.personal_sign(["hello", account]), + /unlock failed/, + ); + + assert.deepEqual(calls, ["requestDisplay", "hide"]); + assert.equal(getHideCount(), 1); + }); + + it("eth_sendTransaction: branding approveAndSignTransaction", async () => { + const { wallet, signer, chainId, calls } = createMocks({ + cachedAddress: account, + }); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls, { + approveAndSignTransaction: async (request) => { + calls.push( + `approveAndSign:${request.to}:${request.data}:${request.chainId}`, + ); + assert.equal(request.address, account); + assert.ok(request.transaction.from); + return TX_HASH; + }, + }), + }); + + const to = EVMAccountAddress( + "0x2222222222222222222222222222222222222222", + ); + const data = HexString("0xa9059cbb"); + const result = await helper.handlers.eth_sendTransaction([ + { from: account, to, data, value: "0x0" }, + ]); + + assert.equal(result, TX_HASH); + assert.ok(calls.includes(`approveAndSign:${to}:${data}:0xaa36a7`)); + assert.ok(!calls.includes("signTransaction:11155111")); + assert.ok(calls.includes("hide")); + }); + + it("eth_sendTransaction: onAuthenticated runs after display release", async () => { + const { signer, chainId, calls } = createMocks({ + cachedAddress: account, + }); + let releaseCount = 0; + const walletWithRelease: SignHelperWallet = { + async requestDisplay() { + calls.push("requestDisplay"); + return { + release() { + releaseCount += 1; + calls.push("release"); + }, + async hide() { + calls.push("hide"); + }, + }; + }, + }; + + const helper = new SignHelper(signer, walletWithRelease, { + getChainId: () => chainId, + onAuthenticated: async () => { + calls.push("onAuthenticated"); + }, + ...brandingSignOptions(signer, calls, { + approveAndSignTransaction: async () => { + calls.push("approveAndSign"); + return TX_HASH; + }, + }), + }); + + await helper.handlers.eth_sendTransaction([ + { + from: account, + to: "0x2222222222222222222222222222222222222222", + data: "0x", + }, + ]); + + assert.deepEqual(calls, [ + "requestDisplay", + "approveAndSign", + "release", + "onAuthenticated", + ]); + assert.equal(releaseCount, 1); + }); + + it("eth_sendTransaction: propagates branding rejection", async () => { + const { wallet, signer, chainId, calls } = createMocks({ + cachedAddress: account, + }); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls, { + approveAndSignTransaction: async () => { + throw new OwsUserRejectedError( + "User rejected the transaction request", + ); + }, + }), + }); + + await assert.rejects( + () => + helper.handlers.eth_sendTransaction([ + { + from: account, + to: "0x2222222222222222222222222222222222222222", + data: "0x", + }, + ]), + (error: unknown) => error instanceof OwsUserRejectedError, + ); + + assert.ok(calls.includes("requestDisplay")); + assert.ok(calls.includes("hide")); + }); + + it("eth_sendTransaction: rejects wrong from account", async () => { + const { wallet, signer, chainId, calls } = createMocks({ + cachedAddress: account, + }); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls), + }); + + await assert.rejects( + () => + helper.handlers.eth_sendTransaction([ + { + from: "0x3333333333333333333333333333333333333333", + to: "0x2222222222222222222222222222222222222222", + data: "0x", + }, + ]), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + }); + + it("eth_sendTransaction: rejects mismatched chainId", async () => { + const { wallet, signer, chainId, calls } = createMocks({ + cachedAddress: account, + }); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls), + }); + + await assert.rejects( + () => + helper.handlers.eth_sendTransaction([ + { + from: account, + to: "0x2222222222222222222222222222222222222222", + data: "0x", + chainId: "0x1", + }, + ]), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + }); + + it("eth_sendTransaction: rejects invalid hash from branding", async () => { + const { wallet, signer, chainId, calls } = createMocks({ + cachedAddress: account, + }); + + const helper = new SignHelper(signer, wallet, { + getChainId: () => chainId, + ...brandingSignOptions(signer, calls, { + approveAndSignTransaction: async () => "not-a-hash" as never, + }), + }); + + await assert.rejects( + () => + helper.handlers.eth_sendTransaction([ + { + from: account, + to: "0x2222222222222222222222222222222222222222", + data: "0x", + }, + ]), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + }); +}); diff --git a/packages/ows-signer-utils/test/solana-signer.test.ts b/packages/ows-signer-utils/test/solana-signer.test.ts new file mode 100644 index 0000000..b1e83f4 --- /dev/null +++ b/packages/ows-signer-utils/test/solana-signer.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { base58 } from "@scure/base"; +import { ConversionUtils, HexString } from "@1shotapi/ows-types"; +import { + digestDataForSolanaBytes, + solanaSignatureFromHex, + solanaSignatureToBytes, +} from "../src/solana/marshal.ts"; + +describe("solana marshal", () => { + it("round-trips ed25519 signature hex ↔ base58", () => { + const bytes = new Uint8Array(64); + for (let i = 0; i < 64; i++) bytes[i] = i; + const hex = ConversionUtils.bytesToHex(bytes); + const branded = solanaSignatureFromHex(hex as `0x${string}`); + assert.equal(branded, base58.encode(bytes)); + assert.deepEqual(solanaSignatureToBytes(branded), bytes); + }); + + it("encodes message bytes as 0x hex for signDigest", () => { + const msg = new TextEncoder().encode("hello"); + const digest = digestDataForSolanaBytes(msg); + assert.equal(digest, HexString("0x68656c6c6f")); + }); +}); diff --git a/packages/ows-signer-utils/test/solana.test.ts b/packages/ows-signer-utils/test/solana.test.ts new file mode 100644 index 0000000..228b531 --- /dev/null +++ b/packages/ows-signer-utils/test/solana.test.ts @@ -0,0 +1,15 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { SolanaAccountAddress } from "@1shotapi/ows-types"; +import { addressFromEd25519PublicKey } from "../src/solana/address.js"; + +describe("solana address", () => { + it("base58-encodes 32-byte ed25519 public key", () => { + const pubkey = + "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" as const; + assert.equal( + addressFromEd25519PublicKey(pubkey), + SolanaAccountAddress("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"), + ); + }); +}); diff --git a/packages/ows-signer-utils/tsconfig.json b/packages/ows-signer-utils/tsconfig.json new file mode 100644 index 0000000..d037e61 --- /dev/null +++ b/packages/ows-signer-utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"] +} diff --git a/packages/ows-signer/CHANGELOG.md b/packages/ows-signer/CHANGELOG.md new file mode 100644 index 0000000..495e380 --- /dev/null +++ b/packages/ows-signer/CHANGELOG.md @@ -0,0 +1,99 @@ +# @1shotapi/ows-signer + +## 0.4.6 + +### Patch Changes + +- 99784eb: Show private-key import in the ceremony bottom sheet and ask for an EVM key to recover 1Shot wallet accounts. + +## 0.4.5 + +### Patch Changes + +- b20bd83: Allow private-key reveal after the passkey sheet blurs the signer iframe. `showPrivateKey` still requires a visible document, and still requires focus unless the Confirm or passphrase click in that ceremony happened while the signer was visible and focused. Copy still checks focus at the click and does not reuse that snapshot. + +## 0.4.4 + +### Patch Changes + +- ec08739: Security remediations from the Signing Layer audit: require same-origin Branding↔Signing (parent origin gate, `createSignerIframe` check, CSP `frame-ancestors 'self'`), show requester origin on Confirm, make `clearSession()` clear the recovery session, and gate reveal/clipboard on document visibility and focus. Publish curated AI-assisted audit notes under `ows-signer/audits/2026-10-23-signing-layer/`. + +## 0.4.3 + +### Patch Changes + +- Passkey ceremony UI uses a compact bottom sheet inside a transparent bottom-anchored host strip instead of a centered floating panel with outer shadow. + +## 0.4.2 + +### Patch Changes + +- 3d52a82: Derive Ed25519 from the secp256k1 scalar (same IKM as `signDigest` / AES-256) so passkey/PRF and recovery/import emit the same pubkey. Existing Solana addresses from the previous PRF-direct HKDF path will change. + +## 0.4.1 + +### Patch Changes + +- Clear stuck passkey ceremonies after timeout so a retry can start, and include RS256 alongside ES256 in WebAuthn create params. + + `ows-signer` now steals a hung ceremony lock, aborts in-flight `credentials.get/create`, and treats cancel/Abort as SignDenied. `ows-signer-utils` posts cancel before each new signer RPC so branding retries do not hit “already pending.” + +## 0.4.0 + +### Minor Changes + +- df9c4b7: Return full WebAuthn assertion fields (`authenticatorData`, `clientDataJSON`, `signature`, `credentialId`) from challenge-capable Signing Layer ceremonies as nested `assertion`, replacing `challengeSignature`. + +## 0.3.4 + +### Patch Changes + +- c8fd466: Add `createCredential` option `deferKeyDerivation` so Safari first-party registration can skip the PRF follow-up; `CredentialCreated.secp256k1PublicKey` is optional when deferred. + +## 0.3.3 + +### Patch Changes + +- ce5535c: Keep the create-passkey Confirm panel visible through the PRF follow-up assertion, and update its copy so the second WebAuthn prompt explains why it is requested. + +## 0.3.2 + +### Patch Changes + +- 09d93bb: Add `importPrivateKey` — paste a hex secp256k1 key in the Signing Layer to start a recovery session. + +## 0.3.1 + +### Patch Changes + +- 08133a8: revealPrivateKey keeps the ceremony panel open until the user dismisses the key UI (PrivateKeyRevealed). + +## 0.3.0 + +### Minor Changes + +- ae79e0f: Batch digest signing and `executeBatch` mixed ceremony so one passkey covers many signs/AES/auth ops; signing APIs are array-only (breaking). +- a39b7b2: Signing Layer passkey Confirm/Cancel UI (ceremony params + `SignDenied` / `OwsSignDeniedError`) and visible ceremony panel; remove invisible 1×1 WebAuthn prep and fold `credentialId` into options bags. + +## 0.2.2 + +### Patch Changes + +- ca64262: Fix recoverable ECDSA `v` for on-chain ecrecover (`27`/`28` instead of yParity `0`/`1`), which caused MetaMask Delegation / OpenZeppelin `ECDSAInvalidSignature()` on relayer estimate. Canonicalize message and typed-data signatures in `EvmSigner`. Prefer optional display `release()` over `hide()` in `SignHelper` so nested host RPC flyouts stay visible. Prefer cached address / public key in `toViemLocalAccount` to avoid an extra WebAuthn ceremony when warming a viem account. + +## 0.2.1 + +### Patch Changes + +- afc99f3: Replace PasskeyPublicKey with branded COSEPublicKey / SPKIPublicKey. Signing Layer emits COSE (`cosePublicKey`) from attestation authenticator data; add COSEToSPKIPublicKey in ows-signer-utils for Web Crypto consumers. + +## 0.2.0 + +### Minor Changes + +- 55f9a22: Implement PRF-derived `encryptAES256` / `decryptAES256` in the Signing Layer: AES-256-GCM keyed via HKDF from the wallet secp256k1 scalar (same material as `signDigest`), `ows-aes1:` envelopes, batch + recovery-session paths. + +### Patch Changes + +- 89e40a9: Phase 3 credentials hardening: `ICredentialRepository`, `IIssuerTrustRegistry.isTrustedIssuer`, `CredentialsHelper`, host `acceptedIssuers` on present, fail-closed status checks, and stubbed `OWSSigner.encryptAES256` / `decryptAES256` with branded `AES256CipherText`. +- a803f5a: Implement OWSSigner SDK with RPC client, EVM signing namespace, and viem LocalAccount adapter. Emit RecoverySessionStarted from ows-signer after recoverKey success. Adopt `@1shotapi/ows-types` for shared signer protocol types and errors. diff --git a/packages/ows-signer/README.md b/packages/ows-signer/README.md new file mode 100644 index 0000000..753ec4f --- /dev/null +++ b/packages/ows-signer/README.md @@ -0,0 +1,133 @@ +# @1shotapi/ows-signer + +The **OWS Signing Layer** — plain JavaScript, zero npm dependencies, no build step. + +## Design + +Minimal **KMS/HSM** iframe: WebAuthn PRF key custody, curve-based signing, optional export/recovery UI. Chain-specific marshalling lives in `@1shotapi/ows-signer-utils`. + +## Deployment and rpId (hybrid) + +- `rpId` is always **`window.location.hostname`** (the signer document origin). +- **Canonical CDN / on-chain gateway:** shared passkey namespace for all integrators on that host. +- **Per-brand rpId:** self-host or reverse-proxy signer HTML on your branding origin. + +## Nesting + +Must be embedded as **iframe inside a Branding Layer iframe** (not directly in the host). Rejects messages unless `window.parent !== window.top`. + +## Wire protocol + +Parent → signer: + +```json +{ "v": 1, "kind": "request", "method": "signDigest", "correlationId": "…", "params": { } } +``` + +Signer → parent: + +```json +{ "v": 1, "kind": "event", "event": "DigestSigned", "correlationId": "…", "data": { } } +``` + +Only accepts messages when `event.source === window.parent`. Replies use the parent's `event.origin` as `targetOrigin`. + +## Methods + +| Method | Params | Success events | +|--------|--------|----------------| +| `getVersion` | — | `Version` | +| `createCredential` | `name`, `options?` (incl. ceremony UI) | `KeyDerived`, `CredentialCreated` | +| `signDigest` | `digests[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `DigestSigned` (`results[]`) | +| `executeBatch` | batch fields + ceremony UI? | `KeyDerived` (when keys derived), `BatchExecuted` | +| `revealPrivateKey` | `credentialId?`, ceremony UI? | `KeyDerived`, then `PrivateKeyRevealed` (after user dismisses DOM key UI) | +| `importPrivateKey` | — | DOM hex input; `KeyDerived`, `RecoverySessionStarted` | +| `createRecoveryData` | passphrase fields + ceremony UI? | `KeyDerived`, `RecoveryDataCreated` | +| `recoverKey` | envelope + passphrase fields + ceremony UI? | DOM display; `RecoverySessionStarted` or re-bind → `RecoverySessionCleared` | +| `getPublicKey` | `credentialId?`, `challenge?`, ceremony UI? | `KeyDerived`, `PublicKey`, `ChallengeSigned?` | +| `clearRecoverySession` | — | `RecoverySessionCleared` | +| `encryptAES256` | `plaintexts[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `AES256Encrypted` | +| `decryptAES256` | `ciphertexts[]`, `credentialId?`, ceremony UI? | `KeyDerived` (PRF path), `AES256Decrypted` | + +Failure events: `SignDenied` (user cancelled Confirm UI), `NotAllowed` (WebAuthn cancel/policy), `InvalidRequest`. + +Ceremony UI params (optional on WebAuthn-triggering methods): `explanationHeader`, `explanationText`, `confirmButtonText`, `denyButtonText`. Defaults: “Confirm passkey” / “Your device will ask for a passkey to continue.” / “Continue” / “Cancel”. Confirm click starts WebAuthn in the same turn (mobile activation). + +`createCredential` options: + +- `rpName` — relying party display name in passkey UI (default `"OWS"`); does not affect `rpId` +- `userDisplayName` — friendly account label (defaults to `name`) +- `userId` — stable user handle bytes (base64); random if omitted +- `deferKeyDerivation` — when true, skip the PRF follow-up get; `CredentialCreated` includes `credentialId` + `cosePublicKey` only (no `KeyDerived` / no `secp256k1PublicKey`) + +## Signing schemes + +`secp256k1-ecdsa`, `secp256k1-ecdsa-recoverable`, `secp256k1-bip340` (not yet implemented), `ed25519`. + +`digestData` is `0x`-prefixed hex. Hash schemes require 32 bytes. `signDigest` and `executeBatch` take **arrays** of digests so one passkey ceremony covers many signatures. Empty `digests` arrays succeed without a ceremony. `executeBatch` with a `challenge` always forces a real WebAuthn assertion (recovery session alone cannot produce an assertion signature). + +## PRF labels + +- `ows-v1/secp256k1` (HKDF from PRF output, rejection sampling into the curve order) +- `ows-v1/ed25519` (HKDF from the secp256k1 scalar — same material as `signDigest` / recovery) +- `ows-v1/aes256-gcm` (HKDF from the secp256k1 scalar — same material as `signDigest`) + +## Recovery envelope + +`ows1:0x…` — version byte, 16-byte salt, 12-byte IV, AES-GCM-256 ciphertext (PBKDF2-SHA256, 250k iterations). + +## AES-256 seal envelope (credentials / general payload) + +`ows-aes1:0x…` — version byte, 12-byte IV, AES-GCM-256 ciphertext+tag. Key is HKDF-SHA256 of the wallet secp256k1 scalar with info `ows-v1/aes256-gcm` (no PBKDF2). Works under a recovery session without a new WebAuthn ceremony. Batch methods amortize one ceremony across many plaintexts/ciphertexts. + +## Recovery session + +`recoverKey` caches the secp256k1 scalar in memory for subsequent `signDigest` / `encryptAES256` / `decryptAES256` / `executeBatch` (without `challenge`) calls without PRF. Cleared via `clearRecoverySession` or successful `credentialId` re-bind after recovery. + +## Layout + +``` +html/index.html Entry document +html/signer.css Ceremony / reveal UI styles (linked; no inline CSS) +src/main.js Bootstrap + message listener +src/handlers.js RPC dispatch +src/webauthn.js Passkey ceremonies +src/crypto/ PRF, signing, recovery +src/crypto/vendor/ Vendored @noble/secp256k1 + ed25519 (MIT) +reference/ 1ShotPay reference (not published) +``` + +## Content Security Policy + +Serve the Signing Layer document (and its `/src/*` modules) with a **fail-closed** CSP. The signer has no legitimate network I/O — only same-origin scripts/styles and `postMessage` / WebAuthn / WebCrypto. + +**Branding and Signing must share the same origin** (e.g. `https://wallet.example/` + `/signer/`). Cross-origin embeds of a shared CDN/IPFS signer are not supported: a hostile Branding could otherwise drive passkey-gated signing with attacker-written Confirm copy. The Signing Layer rejects parent `postMessage` when `event.origin !== location.origin`, and hosts should set `frame-ancestors 'self'` (optionally `X-Frame-Options: SAMEORIGIN`) so foreign pages cannot frame `/signer/` at all. + +Canonical header (single line in production): + +```http +Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; font-src 'none'; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; upgrade-insecure-requests +``` + +- Apply on the **Signing Layer origin path** (e.g. nginx `location /signer/`), not on the Branding Layer SPA. +- Nesting safety also remains in JS (`event.source === window.parent`, `window.parent !== window.top`, same-origin parent). +- Reveal / export UI requires the signer document to be **visible and focused** before showing the key or writing the clipboard (defense against obscured-frame clickjacking). +- Keep iframe Permissions Policy `allow` for WebAuthn / clipboard (set before `src` in `createSignerIframe`) — that is not CSP. + +## Audits + +See [audits/](audits/) for scoped security review notes (AI-assisted). + +## On-chain + +See `@1shotapi/ows-onchain`. + +## Integrity (planned) + +Client-side confirmation that official signer bytes are loaded (signed manifest + verify-before-execute, optional EIP-8244 pin) is **specced and tabled** — see [docs/trusted-loader-plan.md](docs/trusted-loader-plan.md). Not implemented yet. + +## Tests + +```bash +npm test -w @1shotapi/ows-signer +``` diff --git a/packages/ows-signer/audits/2026-10-23-signing-layer/README.md b/packages/ows-signer/audits/2026-10-23-signing-layer/README.md new file mode 100644 index 0000000..6f13b43 --- /dev/null +++ b/packages/ows-signer/audits/2026-10-23-signing-layer/README.md @@ -0,0 +1,63 @@ +# Signing Layer security review — 2026-10-23 + +| Field | Value | +|-------|--------| +| Package | `@1shotapi/ows-signer` (+ Branding helpers in `@1shotapi/ows-signer-utils`) | +| Date | 2026-10-23 | +| Profile | Deep, **scoped** (Signing Layer + Branding↔Signing protocol only) | +| Method | AI-assisted review via the Cursor **security-audit** skill (multi-agent recon, hunt, validate, verify) | +| Human review | Not a traditional human pen-test firm engagement | +| Source under review | Open Wallet monorepo (`prf-wallet`), Signing packages as of the audit run | + +## Disclosure + +This review was **performed by AI agents** guided by a structured security-audit workflow (architecture recon, parallel hunters, independent verifiers, coverage critics). Findings were validated against source. It is **not** a substitute for a dedicated human security assessment, formal certification, or bug-bounty coverage of the full wallet stack. + +Runtime behaviours that depend on browsers, CSP headers, or WebAuthn UX were recorded as needs-validation where source alone could not prove exploitability; product remediations for those items are listed below and in sibling files. + +## Scope + +**In scope** + +- `packages/ows-signer` (Signing Layer: `postMessage`, WebAuthn/PRF, recovery, UI) +- `packages/ows-signer-utils` (Branding↔Signing RPC / `OWSSigner`) +- Related signing types in `packages/ows-types` + +**Out of scope** + +- Host Layer (`ows-provider`), Host↔Branding Postmate (`ows-wallet-utils`) +- OID4 credentials, on-chain deploy pipeline, example apps as production targets + +## Tooling + +- Cursor Agent + security-audit skill (phased recon → hunt → validate → report) +- Source-only verification on Windows (no OS-enforced sandbox / artifact promotion in that environment) +- Schema-validated findings export from the audit run (curated here; raw agent scratch not published) + +## Outcomes (after remediations in this change set) + +| Fingerprint | Audit verdict | Remediation status | +|-------------|---------------|--------------------| +| `ows-signer:recovery-session-survives-clearSession` | confirmed (low) | **Fixed** — `clearSession()` clears recovery | +| `ows-signer/no-branding-identity-binding-any-embedder-full-rpc` | needs_validation | **Mitigated** — same-origin Branding↔Signing + `frame-ancestors 'self'` + Confirm shows requester | +| `ows-signer/ui/reveal-key-clickjack-clipboard-no-visibility-guard` | needs_validation | **Mitigated** — visibility+focus gates; CSP framing | + +Rejected candidates (not treated as vulnerabilities): passphrase min-length (by design), cancel-surviving non-Confirm prompts (hygiene), recoverKey rebind fail-open (no extra capability), unbound signer bytes / deploy CSP (roadmap / operator trust root). + +## Documents in this folder + +| File | Audience | +|------|----------| +| [REPORT.md](REPORT.md) | Human-readable summary of posture and method | +| [findings.md](findings.md) | Curated finding write-ups with post-fix status | +| [findings.json](findings.json) | Machine-readable summary records | + +## Related remediations + +Implemented alongside publication of this pack: + +- Same-origin parent message gate; lock trusted parent origin; Confirm shows requester origin +- `OWSSigner.create` rejects cross-origin signer URLs +- Signing CSP documents and 1Shot wallet nginx/Vite CSP: `frame-ancestors 'self'` +- `OWSSigner.clearSession()` awaits `clearRecoverySession()` +- `showPrivateKey` / Copy require `document` visible and focused diff --git a/packages/ows-signer/audits/2026-10-23-signing-layer/REPORT.md b/packages/ows-signer/audits/2026-10-23-signing-layer/REPORT.md new file mode 100644 index 0000000..9cb548e --- /dev/null +++ b/packages/ows-signer/audits/2026-10-23-signing-layer/REPORT.md @@ -0,0 +1,46 @@ +# Security audit report — Signing Layer (2026-10-23) + +## Run metadata + +| Field | Value | +|-------|--------| +| Profile | `deep` (scoped) | +| Scope | Signing Layer + Branding↔Signing protocol | +| Out of scope | Host Layer, OID4, on-chain deploy, Host↔Branding Postmate | +| Source ref (audit baseline) | `1aa132220ab27a85b88b64b0d6371c197908e317` (`feature/SecurityAudit`) | +| Method | AI-assisted (Cursor security-audit skill); source-validated | +| Execution notes | Source-only on Windows host; CSP/WebAuthn runtime facts marked needs-validation where unproven | + +This is a **partial** pass: Signing Layer only. It does not claim coverage of the full wallet stack. + +## Security posture (at audit time) + +Nesting gates (`event.source === window.parent`, `parent !== top`) and crypto paths (PRF→HKDF, AES-GCM, recovery envelopes) were coherent. Ceremony Confirm + userVerification paths looked intentional. Ceremony copy is sanitized via `textContent` with length caps; private keys are not sent plaintext over `postMessage`. + +**Concrete defect (confirmed):** `OWSSigner.clearSession()` cleared Branding-side caches but did not end the Signing recovery session, so post–Change Account signing could use a cached scalar without Confirm/WebAuthn. + +**Product/threat leads (needs_validation at audit time):** permissionless cross-origin Branding embeds of a shared signer host (Confirm never showed requester origin); reveal/clipboard without visibility/focus framing guards. + +## Findings summary + +| Severity / class | Title | Post-remediation | +|------------------|-------|------------------| +| confirmed / low | Recovery scalar survives `clearSession` | Fixed | +| needs_validation | Any Branding origin full RPC; Confirm hid origin | Mitigated (same-origin + CSP + origin UI) | +| needs_validation | Reveal-key clickjack / clipboard | Mitigated (visibility+focus + CSP) | + +## Positive patterns + +- Double-iframe nesting required before listener registration +- Branding inbound RPC checks both `origin` and `iframe.contentWindow` +- Recovery session documented; `executeBatch` with `challenge` forces WebAuthn even under recovery +- Ceremony copy sanitized via `textContent` with length caps +- Private keys not sent plaintext over `postMessage` + +## Product decision after audit + +Cross-origin “permissionless CDN/IPFS signer” embeds are **retired**. Branding and Signing must share the same origin (e.g. `wallet.1shotapi.com` + `/signer/`). Hostile Brandings must not frame a foreign user’s signer hostname. + +## Rejected candidates + +Not published as vulnerabilities: Branding-controlled passphrase minimum length; passphrase/import prompts surviving `cancel` (same-principal hygiene); recoverKey rebind deny leaving recovery session (same capability as default recover); unbound signer bytes / deploy-only CSP guidance (operator trust root / trusted-loader roadmap). diff --git a/packages/ows-signer/audits/2026-10-23-signing-layer/findings.json b/packages/ows-signer/audits/2026-10-23-signing-layer/findings.json new file mode 100644 index 0000000..e3d2002 --- /dev/null +++ b/packages/ows-signer/audits/2026-10-23-signing-layer/findings.json @@ -0,0 +1,43 @@ +{ + "audit": { + "id": "2026-10-23-signing-layer", + "package": "@1shotapi/ows-signer", + "profile": "deep-scoped", + "method": "ai-assisted-cursor-security-audit-skill", + "human_pen_test": false, + "source_ref": "1aa132220ab27a85b88b64b0d6371c197908e317", + "date": "2026-10-23" + }, + "findings": [ + { + "fingerprint": "ows-signer:recovery-session-survives-clearSession", + "title": "Signing recovery scalar survives OWSSigner.clearSession", + "audit_verdict": "confirmed", + "severity": "low", + "status": "fixed", + "remediation": "clearSession awaits clearRecoverySession; Change Account awaits clearSession" + }, + { + "fingerprint": "ows-signer/no-branding-identity-binding-any-embedder-full-rpc", + "title": "Signing Layer granted full RPC to any embedding Branding origin", + "audit_verdict": "needs_validation", + "severity": null, + "status": "mitigated", + "remediation": "same-origin parent gate; create() same-origin check; Confirm shows origin; frame-ancestors 'self'" + }, + { + "fingerprint": "ows-signer/ui/reveal-key-clickjack-clipboard-no-visibility-guard", + "title": "Reveal-key UI lacked visibility/focus guards", + "audit_verdict": "needs_validation", + "severity": null, + "status": "mitigated", + "remediation": "visibility+focus before showPrivateKey and clipboard; framing CSP" + } + ], + "rejected_fingerprints": [ + "ows-signer/passphrase-import-prompt-survives-cancel", + "ows-signer/passphrase-min-length-branding-controlled", + "ows-signer/recoverKey-rebind-denied-leaves-recovery-session", + "ows-signer/unbound-signer-bytes-no-implemented-integrity-or-in-repo-csp" + ] +} diff --git a/packages/ows-signer/audits/2026-10-23-signing-layer/findings.md b/packages/ows-signer/audits/2026-10-23-signing-layer/findings.md new file mode 100644 index 0000000..ebe433c --- /dev/null +++ b/packages/ows-signer/audits/2026-10-23-signing-layer/findings.md @@ -0,0 +1,51 @@ +# Findings — Signing Layer audit 2026-10-23 + +Statuses reflect remediations landed with this audit pack. + +--- + +## Fixed — `ows-signer:recovery-session-survives-clearSession` + +| Field | Value | +|-------|--------| +| Audit verdict | confirmed | +| Overall severity | low (impact medium, likelihood low) | +| Status | **fixed** | + +**Issue.** After `importPrivateKey` / `recoverKey`, `OWSSigner.clearSession()` cleared Branding caches and rejected pending RPCs but did not call `clearRecoverySession`. Signing kept the recovery scalar; later `signDigest` / AES used Confirm-less recovery paths. + +**Remediation.** `clearSession()` is async and awaits `clearRecoverySession()`. Embedded wallet Change Account awaits `clearSession()`. + +--- + +## Mitigated — `ows-signer/no-branding-identity-binding-any-embedder-full-rpc` + +| Field | Value | +|-------|--------| +| Audit verdict | needs_validation | +| Status | **mitigated** | + +**Issue.** Listener accepted any same-nesting parent without origin identity; `trustedParentOrigin` was stored but unused; Confirm UI showed only Branding-supplied copy. README advised omitting `frame-ancestors` for permissionless embeds. A hostile Branding on another origin could embed a shared signer host and drive signing after one Confirm click on attacker-written copy (without exfiltrating the key over RPC). + +**Remediation.** + +- Reject parent messages unless `event.origin === location.origin`; lock first trusted origin +- `OWSSigner.create` / `createSignerIframe` require same-origin signer URL +- Confirm panel shows requester origin +- CSP guidance and 1Shot wallet deploy: `frame-ancestors 'self'` + +--- + +## Mitigated — `ows-signer/ui/reveal-key-clickjack-clipboard-no-visibility-guard` + +| Field | Value | +|-------|--------| +| Audit verdict | needs_validation | +| Status | **mitigated** | + +**Issue.** `revealPrivateKey` → `showPrivateKey` could display hex and clipboard-write on Copy without Page Visibility / focus checks, while the signer was intentionally frameable by arbitrary Brandings. + +**Remediation.** + +- Require `document.visibilityState === "visible"` and `document.hasFocus()` before showing the key UI; re-check before clipboard write +- Same-origin + `frame-ancestors 'self'` reduce foreign framing diff --git a/packages/ows-signer/docs/trusted-loader-plan.md b/packages/ows-signer/docs/trusted-loader-plan.md new file mode 100644 index 0000000..168e336 --- /dev/null +++ b/packages/ows-signer/docs/trusted-loader-plan.md @@ -0,0 +1,171 @@ +# OWS Signing Layer — Trusted Loader Plan + +**Status:** Spec / design only — **tabled**. No implementation until revisited. + +This document records how the Branding Layer can cryptographically confirm that the Signing Layer (`ows-signer`) bytes it loads match an official release, defeating cache poisoning and many MITM-adjacent failures beyond TLS alone. + +--- + +## Goal + +Raise the bar so an attacker must compromise **both**: + +1. The Branding Layer (or its verifier), **and** +2. The Signing Layer payload (or its trust roots), + +before a malicious custody kernel can run. HTTPS remains mandatory; integrity checks are defense-in-depth on top. + +**Non-goal:** Fully defeating a malicious browser extension that can rewrite branding JS before the verifier runs. Document that limit honestly; design for dual-compromise cost and managed environments (WebView / no extensions) where the model is strongest. + +--- + +## Why not “just HTTPS / ETag / getVersion” + +| Mechanism | Integrity? | Role | +|-----------|------------|------| +| HTTPS/TLS | Transport only | Required baseline | +| ETag / Last-Modified | No | Cache validators, not signatures | +| `getVersion` RPC | No | Attestation after load; a malicious kernel can lie | +| SRI (`integrity=`) | Yes, per file | Useful building block; awkward alone for a multi-file ES module graph | +| Signed manifest + verify-before-execute | Yes | Primary client-side pattern | +| EIP-8244 on-chain artifact | Yes (pinned address) | Second, immutable trust root | + +There is no W3C “sign this iframe app” API. The workable pattern is the same idea as the historical IPFS + trusted loader patent: **fetch → verify → only then execute**. + +--- + +## Architecture + +``` +Release pipeline + ows-signer sources (plain JS) + │ + ├─► npm @1shotapi/ows-signer (verbatim bytes) + ├─► signed manifest (file digests + signature) + └─► ows-onchain / EIP-8244 (optional immutable html()/hash) + +Branding Layer (ows-signer-utils) + TrustedLoader + │ 1. Load trust root (embedded pubkey / pinned contract) + │ 2. Fetch manifest + signature (or embed at branding build time) + │ 3. Verify signature over manifest + │ 4. Fetch each listed file; SHA-384 (SRI-compatible) match + │ 5. Fail closed OR boot signer via blob URLs / verified bootstrap + ▼ + Nested Signing Layer iframe (current nesting rules unchanged) +``` + +### Trust roots (pick at least one; prefer both) + +1. **Publisher signed manifest** — Ed25519 (or Sigstore-style) over a canonical JSON manifest. Public key shipped in branding / `ows-signer-utils` config. Fast updates. +2. **On-chain pin** — EIP-8244 contract address + content hash for a given `signerVersion`. Branding compares fetched/gzipped bytes (or gateway output) to the pinned digest. Immutable second root. + +For a given release, signed-manifest digests and on-chain hash **must agree**. + +### Manifest sketch (illustrative) + +```json +{ + "format": "ows-signer-manifest/v1", + "signerVersion": "0.x.y", + "apiVersion": 1, + "hashAlg": "sha384", + "files": [ + { "path": "html/index.html", "digest": "sha384-…" }, + { "path": "src/main.js", "digest": "sha384-…" } + ], + "onchain": { + "chainId": 1, + "contract": "0x…", + "contentHash": "0x…" + } +} +``` + +Signature covers a stable serialization of the manifest (exact encoding TBD at implementation time). + +### Loader placement + +| Piece | Package | Notes | +|-------|---------|--------| +| Signer sources + this plan | `ows-signer` | Kernel stays plain JS, zero deps | +| Manifest emit + on-chain hash | `ows-onchain` (+ release CI) | Same bytes as npm publish | +| `TrustedLoader` / `OWSSigner.create({ integrity })` | `ows-signer-utils` | Branding Layer owns verify-before-execute | +| Branding config (pubkey, allowed versions) | Integrator / general-wallet | Walmart pins official releases | + +**Hard rule preserved:** Host never embeds Signing Layer directly. Loader runs in Branding Layer only. + +--- + +## Threat model (summary) + +**Mitigated or raised cost** + +- CDN / reverse-proxy cache poisoning of signer assets +- Accidental wrong-version deploy behind a stable URL +- Passive MITM that cannot forge the publisher signature / on-chain hash +- Integrator proxy that forgets to update but still serves old pinned URL (fail closed if version/hash mismatch) + +**Not fully mitigated** + +- Extension or malware that patches branding **before** the loader runs +- Compromised branding build that ships a malicious trust root +- User ignoring fail-closed UI and forcing an unverified path (if any escape hatch exists — prefer none in production) + +--- + +## Implementation phases (when un-tabled) + +### Phase 0 — Spec freeze (this doc) + +- [x] Record goals, trust roots, package boundaries, threat limits +- [ ] Choose signature format (raw Ed25519 vs Sigstore) and manifest canonicalization +- [ ] Decide boot strategy: blob URL document vs same-origin verified copy vs CSP+SRI-only for single-file gateway HTML + +### Phase 1 — Release artifacts + +- Emit `manifest.json` + detached signature from CI for every `ows-signer` release +- Content-address or pin digests for every file under `html/` and `src/` +- Align `ows-onchain` build so on-chain `contentHash` matches manifest + +### Phase 2 — Branding loader (`ows-signer-utils`) + +- `verifySignerRelease({ baseUrl | bytes, manifest, trust })` → fail closed +- Integrate into `OWSSigner.create` / iframe creation path +- Optional: embed last-known-good manifest in branding build for offline pin + +### Phase 3 — Hardening + +- Strict CSP on signer document — **baseline shipped** in branding hosts (e.g. 1Shot wallet nginx `/signer/`): `default-src 'none'`, `script-src`/`style-src 'self'`, `connect-src 'none'`, `base-uri 'none'`, **`frame-ancestors 'self'`** (Branding and Signing same origin only). See package README. Future: per-file SRI / hashes when trusted-loader lands. +- Display signer fingerprint / version in branding UI during sensitive ops +- Document Walmart-style proxy: proxy official bytes, still run loader against pinned trust root +- Explicit “no unverified boot” production default + +### Phase 4 — Optional + +- Import-map + SRI where browser support is enough +- Isolated Web App / managed WebView guidance for extension-hostile deployments + +--- + +## Open decisions (parked) + +1. **Boot medium:** blob: iframe origin vs verified files on branding origin (affects `rpId` / WebAuthn hostname — must stay consistent with hybrid rpId model in README). +2. **Trust key rotation:** how branding learns new publisher keys without TOFU weakness. +3. **Dev mode:** local unsigned signer for `npm run dev` vs always-signed fixtures. +4. **Single-file on-chain HTML vs multi-file npm tree:** whether loader verifies gateway bootstrap only, or full module graph when self-hosting. + +--- + +## Relation to existing code + +- Nesting and `postMessage` rules in `README.md` stay as-is. +- `getVersion` remains a post-boot health/compat signal, **not** an integrity proof. +- `createSignerIframe` in `ows-signer-utils` is the natural insertion point for the loader. +- `packages/ows-onchain` is the planned immutable anchor; scaffold exists today. + +--- + +## Recommendation when revisiting + +Ship **signed manifest + verify-before-execute** first (works for npm/CDN/proxy). Add **EIP-8244 hash pin** as the second root once the on-chain pipeline emits stable digests. Treat extension resistance as operational (managed browsers) plus dual-layer compromise cost, not as a solved web-platform problem. diff --git a/packages/ows-signer/html/index.html b/packages/ows-signer/html/index.html new file mode 100644 index 0000000..cd94d3d --- /dev/null +++ b/packages/ows-signer/html/index.html @@ -0,0 +1,13 @@ + + + + + + OWS Custody Signer + + + +
+ + + diff --git a/packages/ows-signer/html/signer.css b/packages/ows-signer/html/signer.css new file mode 100644 index 0000000..b9bf3b2 --- /dev/null +++ b/packages/ows-signer/html/signer.css @@ -0,0 +1,275 @@ +:root { + color-scheme: light; + --ows-bg: #f3f4f6; + --ows-surface: #ffffff; + --ows-border: #e5e7eb; + --ows-text: #111827; + --ows-text-muted: #6b7280; + --ows-primary: #2a9aa8; + --ows-primary-hover: #238a97; + --ows-radius-card: 20px; + --ows-radius-control: 10px; + font-family: + system-ui, + -apple-system, + "Segoe UI", + Roboto, + Helvetica, + Arial, + sans-serif; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + background: var(--ows-bg); + color: var(--ows-text); +} + +.ows-signer-ui { + display: flex; + flex-direction: column; + gap: 12px; + box-sizing: border-box; + align-items: stretch; + justify-content: center; + margin: auto; + width: calc(100% - 24px); + max-width: 360px; + height: auto; + max-height: calc(100% - 24px); + padding: 28px 24px; + overflow: auto; + background: var(--ows-surface); + border: 1px solid var(--ows-border); + border-radius: var(--ows-radius-card); + box-shadow: 0 1px 2px rgb(0 0 0 / 4%); + text-align: center; +} + +body.ows-ceremony-open { + align-items: stretch; + justify-content: flex-end; + background: transparent; +} + +.ows-signer-ui--ceremony { + gap: 0; + margin: 0; + width: 100%; + max-width: none; + height: 100%; + max-height: 100%; + min-height: 0; + padding: 12px 20px 0; + justify-content: flex-start; + overflow: hidden; + border: 1px solid var(--ows-border); + border-bottom: none; + border-radius: var(--ows-radius-card) var(--ows-radius-card) 0 0; + box-shadow: 0 -2px 16px rgb(0 0 0 / 6%); + animation: ows-sheet-in 280ms cubic-bezier(0.32, 0.72, 0, 1) both; +} + +@keyframes ows-sheet-in { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0); + } +} + +.ows-ceremony-grab { + flex: 0 0 auto; + width: 2.5rem; + height: 0.25rem; + border-radius: 999px; + background: color-mix(in srgb, var(--ows-text-muted) 35%, transparent); +} + +.ows-signer-ui--ceremony .ows-ceremony-grab { + margin: 0 auto 12px; +} + +.ows-signer-ui--ceremony .ows-ceremony-header { + flex-shrink: 0; + padding: 0; + margin: 0 0 8px; + text-align: left; +} + +.ows-signer-ui--ceremony .ows-ceremony-body { + flex: 0 0 auto; + flex-shrink: 0; + min-height: auto; + overflow: visible; + padding: 0 0 4px; + margin-bottom: 12px; + text-align: left; +} + +.ows-signer-ui--ceremony .ows-ceremony-actions { + padding-bottom: max(16px, env(safe-area-inset-bottom, 0px)); +} + +.ows-signer-ui--key-entry .ows-ceremony-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.ows-signer-ui--key-entry .ows-password { + margin-top: 12px; +} + +.ows-signer-ui--key-entry .ows-password + .ows-ceremony-text { + margin-top: 8px; +} + +.ows-signer-ui:empty { + display: none; +} + +.ows-key { + margin: 0; + padding: 12px; + border: 1px solid var(--ows-border); + border-radius: var(--ows-radius-control); + background: var(--ows-surface); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--ows-text); + word-break: break-all; + white-space: pre-wrap; + overflow: auto; + flex: 1 1 auto; + min-height: 0; + text-align: left; +} + +.ows-password { + box-sizing: border-box; + width: 100%; + padding: 12px 14px; + border: 1px solid var(--ows-border); + border-radius: var(--ows-radius-control); + background: var(--ows-surface); + color: var(--ows-text); + font: inherit; +} + +.ows-password:focus { + outline: 2px solid color-mix(in srgb, var(--ows-primary) 45%, transparent); + outline-offset: 1px; +} + +.ows-ceremony-header { + flex: 0 0 auto; + margin: 0 0 8px; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.3; + color: var(--ows-text); +} + +.ows-ceremony-origin { + margin: 8px 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--ows-text-muted); + word-break: break-all; +} + +.ows-ceremony-body { + flex: 0 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + margin: 0 0 24px; +} + +.ows-ceremony-text { + margin: 0; + font-size: 1rem; + font-weight: 400; + line-height: 1.45; + color: var(--ows-text-muted); +} + +.ows-ceremony-actions, +.ows-actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 12px; + width: 100%; +} + +.ows-copy, +.ows-confirm, +.ows-deny { + box-sizing: border-box; + width: 100%; + min-height: 44px; + padding: 12px 16px; + border-radius: var(--ows-radius-control); + font: inherit; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + align-self: stretch; +} + +.ows-confirm { + border: none; + background: var(--ows-primary); + color: #ffffff; +} + +.ows-confirm:hover:not(:disabled) { + background: var(--ows-primary-hover); +} + +.ows-confirm:focus-visible { + outline: 2px solid var(--ows-primary); + outline-offset: 2px; +} + +.ows-deny, +.ows-copy { + border: 1px solid var(--ows-border); + background: var(--ows-surface); + color: #4b5563; + font-weight: 500; +} + +.ows-deny:hover:not(:disabled), +.ows-copy:hover:not(:disabled) { + background: #f9fafb; +} + +.ows-deny:focus-visible, +.ows-copy:focus-visible { + outline: 2px solid color-mix(in srgb, var(--ows-primary) 50%, transparent); + outline-offset: 2px; +} + +.ows-confirm:disabled, +.ows-deny:disabled, +.ows-copy:disabled { + opacity: 0.45; + cursor: default; +} diff --git a/packages/ows-signer/package.json b/packages/ows-signer/package.json new file mode 100644 index 0000000..99e2b05 --- /dev/null +++ b/packages/ows-signer/package.json @@ -0,0 +1,38 @@ +{ + "name": "@1shotapi/ows-signer", + "version": "0.4.6", + "description": "OWS Signing Layer — plain JavaScript WebAuthn PRF key custody with curve-based signing (on-chain via EIP-8244)", + "type": "module", + "files": [ + "src", + "html", + "README.md" + ], + "exports": { + "./html": "./html/index.html", + "./html/signer.css": "./html/signer.css", + "./src/*": "./src/*" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "keywords": [ + "ows", + "open-wallet-standard", + "webauthn", + "prf", + "passkey", + "signer", + "custody", + "secp256k1" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-signer" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ows-signer/reference/README.md b/packages/ows-signer/reference/README.md new file mode 100644 index 0000000..12be887 --- /dev/null +++ b/packages/ows-signer/reference/README.md @@ -0,0 +1,6 @@ +# Reference material (not published) + +1ShotPay Branding Layer reference copies for porting patterns. **Not included in npm or on-chain artifacts.** + +- `walletFrame.ts` — PRF authentication flow (server-backed; do not port auth to ows-signer) +- `walletExample.ts` — Postmate RPC surface (Branding Layer, not Signing Layer) diff --git a/packages/ows-signer/reference/walletExample.ts b/packages/ows-signer/reference/walletExample.ts new file mode 100644 index 0000000..174a7b8 --- /dev/null +++ b/packages/ows-signer/reference/walletExample.ts @@ -0,0 +1,987 @@ +/** + * This page contains the actual derived wallet for internal 1ShotPay use only. + * It is hosted via an iframe in the parent window and uses the Postmate library to communicate with the parent window. + * This version has stricter security (X-Frame-Options: SAMEORIGIN) - only allows same-origin iframe embedding. + */ + +"use client"; + +import { + BigNumberString, + EVMAccountAddress, + Signature, +} from "@1shotapi/1shotpay-common"; +import { + createCaveat, + createDelegation, + Delegation, + getSmartAccountsEnvironment, + signDelegation, +} from "@metamask/smart-accounts-kit"; +import { DelegationManager } from "@metamask/smart-accounts-kit/contracts"; +import { createCaveatBuilder } from "@metamask/smart-accounts-kit/utils"; +import { ethers } from "ethers"; +import { errAsync, okAsync, ResultAsync } from "neverthrow"; +import Postmate from "postmate"; +import { useState, useEffect } from "react"; +import { + createPublicClient, + createWalletClient, + http, + concat, + pad, + toHex, + encodeAbiParameters, + parseAbiParameters, + getAddress, + keccak256, + Hex, + verifyMessage, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { signAuthorization } from "viem/actions"; +import { base } from "viem/chains"; + +import { + decryptAESEncryptedString, + deriveAESKeyFromString, + encryptString, + getERC3009Signature, + signatureComponentsToSignature, +} from "@/clientUtils/ClientCrypto"; +import { + BASE_CHAIN_ID, + DELEGATION_MANAGER_CONTRACT_ADDRESS, + ERC20_BALANCE_CHANGE_ENFORCER_ADDRESS, + ERC20_BALANCE_LIMIT_ENFORCER_ADDRESS, + ERC7710_CONTRACT_ADDRESS, + SUB_ACCOUNT_7702_CONTRACT_ADDRESS, + USDC_TOKEN_ADDRESS, +} from "@/clientUtils/constants"; +import { + IAuthenticationResult, + IAuthorizeERC7710UpgradeParams, + IAuthorizeERC7710UpgradeResponse, + IAuthorizeSubAccountUpgradeParams, + IAuthorizeSubAccountUpgradeResponse, + ICreateDisableDelegationDelegationParams, + ICreateSubAccountDelegationParams, + IGetAccountAddressResponse, + IGetAddSignerSignatureParams, + IGetAddSignerSignatureResponse, + IGetERC3009SignatureParams, + IGetERC3009SignatureResponse, + IGetSubAccountAddressParams, + IGetSubAccountAddressResponse, + ISignInParams, + ISignInWithRecoveryPhraseParams, + IStoreEncryptedEvmPrivateKeyParams, + IStoreEncryptedEvmPrivateKeyResponse, +} from "@/clientUtils/ProxyTypes"; +import { + IFullAuthenticationResult, + WalletFrame, +} from "@/clientUtils/WalletUtils"; +import CopyableLink from "@/components/CopyableLink"; +import { Button } from "@/components/ui/button"; +import { Field } from "@/components/ui/field"; +import { Toaster } from "@/components/ui/sonner"; +import { USER_ID_KEY, USERNAME_KEY } from "@/constants"; +import { ToastProvider } from "@/contexts/ToastContext"; +import { ECopyableLinkMode } from "@/interfaces/types/enum"; +import { + UserModel, + UserWithRecoveryDataModel, +} from "@/interfaces/types/models"; +import { + AccountRecoveryId, + EVMContractAddress, + EVMPrivateKey, + JSONString, +} from "@/interfaces/types/primitives"; + +// WalletFrame instance - initialized when Postmate is ready +let walletFrame: WalletFrame | null = null; +let handshakePromise: Promise | null = null; + +const ADD_SIGNER_TYPE = "SubWallet:addSigner(address,uint256,uint256)"; +// const REMOVE_SIGNER_TYPE = "SubWallet:removeSigner(address,uint256,uint256)"; + +function initializePostmate(): void { + // Only initialize in browser - check typeof window, not window != null + if (typeof window === "undefined") { + return; + } + + // Only initialize once + if (handshakePromise !== null) { + return; + } + + const postmateModel = new Postmate.Model({ + // Returns the current user if the current session is valid + // Never does a passkey ceremony - just checks session status + getStatus: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper(params, () => { + return walletFrame! + .fetchJson<{ session: boolean; user?: UserModel }>("/api/user") + .map((sessionData) => { + // If session is valid and user exists, return success + if (sessionData.session && sessionData.user) { + // Update authResult with user info but keep walletUnlocked as false + // (wallet will be unlocked on-demand when signatures are needed) + const authResult: IAuthenticationResult = { + success: true, + user: sessionData.user, + walletUnlocked: false, + }; + walletFrame!.setAuthResult(authResult); + + return authResult; + } else { + // No valid session + return { + success: false, + walletUnlocked: false, + error: "No valid session", + } satisfies IAuthenticationResult; + } + }) + .orElse((error) => { + return okAsync({ + success: false, + walletUnlocked: false, + error: error.message || "Failed to check session status", + } satisfies IAuthenticationResult); + }); + }); + }, + + // Expose your model to the Parent. Property values may be functions, promises, or regular values + signIn: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + console.debug( + "During sign in, navigator.userActivation.isActive", + navigator.userActivation.isActive, + ); + walletFrame.rpcWrapper( + params, + (callParams) => { + const authResult = walletFrame!.getAuthResultSync(); + // If we already authenticated, we don't need to do it again + if (authResult != null && authResult.success) { + console.log("User is already logged in, skipping passkey ceremony"); + return okAsync({ + success: authResult.success, + walletUnlocked: authResult.walletUnlocked, + user: authResult.user, + error: authResult.error, + canRetry: authResult.canRetry, + } satisfies IAuthenticationResult); + } + + // This is where we'd call authenticateWithPasskey + // NOTE: This is not awaited intentionally. These postmate model methods do not have a return value. + // Emiting the event at the end of the function is equivalent to a return value. + return walletFrame! + .authenticateWithPasskey(callParams.username) + .map((auth) => { + walletFrame!.setAuthResult(auth); + return { + success: auth.success, + walletUnlocked: auth.walletUnlocked, + user: auth.user, + error: auth.error, + canRetry: auth.canRetry, + } satisfies IAuthenticationResult; + }); + }, + ); + }, + + signInWithRecoveryPhrase: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + ISignInWithRecoveryPhraseParams, + IAuthenticationResult + >(params, ({ accountRecoveryId, accountRecoveryPhrase }) => { + const authResult = walletFrame!.getAuthResultSync(); + // If we already authenticated, we don't need to do it again + if (authResult != null) { + return okAsync({ + success: authResult.success, + walletUnlocked: authResult.walletUnlocked, + user: authResult.user, + error: authResult.error, + canRetry: authResult.canRetry, + } satisfies IAuthenticationResult); + } + + // Fetch the user's recovery data from the server + return walletFrame! + .fetchJson(`/api/user/recover`, { + method: "POST", + body: JSON.stringify({ + accountRecoveryId, + }), + }) + .andThen((userWithRecoveryData) => { + return deriveAESKeyFromString( + accountRecoveryPhrase, + userWithRecoveryData.id.toLowerCase(), + ) + .andThen((aesKey) => { + return decryptAESEncryptedString( + userWithRecoveryData.accountRecoveryData, + aesKey, + userWithRecoveryData.id.toLowerCase(), + ); + }) + .andThen((evmPrivateKey) => { + return completeAuthentication( + accountRecoveryId, + EVMPrivateKey(evmPrivateKey), + userWithRecoveryData, + ); + }) + .andThen((authResultValue) => { + if ( + authResultValue.success == false || + authResultValue.user == null + ) { + return errAsync(new Error("Authentication failed")); + } + // Set the global authResult so getAccountAddress and other functions can use it + walletFrame!.setAuthResult(authResultValue); + return okAsync({ + success: true, + user: new UserModel( + authResultValue.user.id, + authResultValue.user.username, + authResultValue.user.accountAddress, + authResultValue.user.profileText, + authResultValue.user.profileImageUrl, + true, + authResultValue.user.hasApiToken, + authResultValue.user.subAccountsEnabled, + authResultValue.user.hasSubscriptions, + ), + walletUnlocked: authResultValue.walletUnlocked, + } satisfies IAuthenticationResult); + }); + }) + .orElse((err) => { + console.warn("Error during authentication", err); + return okAsync({ + success: false, + walletUnlocked: false, + error: err.message, + canRetry: true, + } satisfies IAuthenticationResult); + }); + }); + }, + + signOut: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper(params, () => { + // Clear the authentication result but leave Postmate handshake intact + walletFrame!.clearAuthResult(); + console.log("Wallet iframe: authResult cleared (signed out)"); + return okAsync({}); + }); + }, + + storeEncryptedEvmPrivateKey: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + IStoreEncryptedEvmPrivateKeyParams, + IStoreEncryptedEvmPrivateKeyResponse + >(params, ({ passphrase, userId }) => { + return walletFrame! + .assureWallet() + .andThen((wallet) => { + // Derive AES key from passphrase and user ID + return deriveAESKeyFromString( + passphrase, + userId.toLowerCase(), + ).andThen((aesKey) => { + // Encrypt the EVM private key + return encryptString( + wallet.privateKey, // Encrypt the wallet's private key + aesKey, + userId.toLowerCase(), // Use user ID as initialization vector + ); + }); + }) + .andThen((encryptedData) => { + // Send backup data to server + return walletFrame!.fetchJson( + `/api/user/backup`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", // Include cookies + body: JSON.stringify({ + encryptedData, + }), + }, + ); + }) + .map((accountRecoveryId) => { + // Update the registered user with the backup data + return { + success: true, + accountRecoveryId: accountRecoveryId, + } satisfies IStoreEncryptedEvmPrivateKeyResponse; + }); + }); + }, + + getERC3009Signature: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + IGetERC3009SignatureParams, + IGetERC3009SignatureResponse + >(params, ({ destinationAddress, amount, validUntil, validAfter }) => { + return walletFrame!.assureWallet().andThen((wallet) => { + return getERC3009Signature( + wallet, + destinationAddress, + amount, + validUntil, + validAfter, + ).map((signedTransfer) => { + return { + transfer: signedTransfer, + } satisfies IGetERC3009SignatureResponse; + }); + }); + }); + }, + + authorizeERC7710Upgrade: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + IAuthorizeERC7710UpgradeParams, + IAuthorizeERC7710UpgradeResponse + >(params, () => { + return walletFrame!.assureWallet().andThen((wallet) => { + const account = privateKeyToAccount( + wallet.privateKey as `0x${string}`, + ); + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }); + return ResultAsync.fromPromise( + publicClient.getTransactionCount({ + address: account.address, + blockTag: "pending", + }), + (e) => e as Error, + ) + .andThen((nonce) => { + const walletClient = createWalletClient({ + account, + chain: base, + transport: http(), + }); + return ResultAsync.fromPromise( + signAuthorization(walletClient, { + contractAddress: ERC7710_CONTRACT_ADDRESS as `0x${string}`, + chainId: BASE_CHAIN_ID, + nonce, + }), + (e) => e as Error, + ); + }) + .map((authorization) => { + const signature = signatureComponentsToSignature( + authorization.r, + authorization.s, + authorization.yParity as 0 | 1, + ); + return { + address: authorization.address, + nonce: authorization.nonce.toString(), + chainId: authorization.chainId.toString(), + signature, + } satisfies IAuthorizeERC7710UpgradeResponse; + }); + }); + }); + }, + + createSubAccountDelegation: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper( + params, + (p) => { + return walletFrame!.assureWallet().andThen((wallet) => { + const account = privateKeyToAccount( + wallet.privateKey as `0x${string}`, + ); + const mainAccountAddress = account.address; + const delegateAddress = p.delegateAddress as `0x${string}`; + const toBigInt = ( + v: number | string | null | undefined, + ): bigint | undefined => { + if (v === undefined || v === null) return undefined; + const n = typeof v === "string" ? BigInt(v) : BigInt(v); + return n > 0n ? n : undefined; + }; + + const amountPerDayBigInt = toBigInt(p.amountPerDay); + const totalAmountBigInt = toBigInt(p.totalAmount); + const minBalanceBigInt = toBigInt(p.minBalance); + const maxSpendPerCallBigInt = toBigInt(p.maxSpendPerCall); + + const environment = getSmartAccountsEnvironment(base.id); + const usdcAddress = USDC_TOKEN_ADDRESS as `0x${string}`; + const scope = { + type: "functionCall" as const, + targets: [usdcAddress], + selectors: ["transfer(address,uint256)"], + }; + + const caveatBuilder = createCaveatBuilder(environment); + + const value = encodeAbiParameters( + [{ type: "address" }], + [p.subAccountAddress as `0x${string}`], + ); + + caveatBuilder.addCaveat("allowedCalldata", { + startIndex: 4, + value, + }); + + if (totalAmountBigInt != null) { + caveatBuilder.addCaveat("erc20TransferAmount", { + tokenAddress: usdcAddress, + maxAmount: totalAmountBigInt, + }); + } + + if (amountPerDayBigInt != null) { + caveatBuilder.addCaveat("erc20PeriodTransfer", { + tokenAddress: usdcAddress, + periodAmount: amountPerDayBigInt, + periodDuration: 86400, + startDate: Math.floor(Date.now() / 1000), + }); + } + + if (minBalanceBigInt != null) { + // ERC20BalanceLimitEnforcer terms: 53 bytes = 1 (enforceLowerLimit) + 20 (token) + 32 (amount) + const terms = concat([ + "0x01" as `0x${string}`, + pad(usdcAddress, { size: 20 }), + pad(toHex(minBalanceBigInt), { size: 32 }), + ]); + caveatBuilder.addCaveat( + createCaveat( + ERC20_BALANCE_LIMIT_ENFORCER_ADDRESS as `0x${string}`, + terms, + ), + ); + } + + if (maxSpendPerCallBigInt != null) { + // ERC20BalanceChangeEnforcer terms (delegation-framework): 73 bytes = + // 1 (0x01 = max decrease) + 20 (token) + 20 (recipient) + 32 (amount). + const maxSpendTerms = concat([ + "0x01" as `0x${string}`, + pad(usdcAddress, { size: 20 }), + pad(mainAccountAddress as `0x${string}`, { size: 20 }), + pad(toHex(maxSpendPerCallBigInt), { size: 32 }), + ]); + caveatBuilder.addCaveat( + createCaveat( + ERC20_BALANCE_CHANGE_ENFORCER_ADDRESS as `0x${string}`, + maxSpendTerms, + ), + ); + } + + const caveats = caveatBuilder.build(); + const delegationWithoutSignature = createDelegation({ + to: delegateAddress, + from: mainAccountAddress, + environment, + scope, + caveats, + }); + + return ResultAsync.fromPromise( + signDelegation({ + privateKey: wallet.privateKey as `0x${string}`, + delegation: delegationWithoutSignature, + chainId: base.id, + delegationManager: environment.DelegationManager, + }), + (e) => e as Error, + ).map((signature) => { + const signedDelegation = { + ...delegationWithoutSignature, + signature, + }; + return { + delegate: signedDelegation.delegate, + delegator: signedDelegation.delegator, + authority: signedDelegation.authority, + caveats: signedDelegation.caveats.map((c) => ({ + enforcer: c.enforcer, + terms: c.terms, + args: c.args, + })), + salt: signedDelegation.salt, + signature: signedDelegation.signature, + } satisfies Delegation; + }); + }); + }, + ); + }, + + createDisableDelegationDelegation: async ( + params: JSONString, + ): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + ICreateDisableDelegationDelegationParams, + Delegation + >(params, (p) => { + return walletFrame!.assureWallet().andThen((wallet) => { + const account = privateKeyToAccount( + wallet.privateKey as `0x${string}`, + ); + const mainAccountAddress = account.address; + const delegateAddress = p.delegateAddress as `0x${string}`; + const delegationToCancel = p.delegationToCancel as Delegation; + const environment = getSmartAccountsEnvironment(base.id); + const delegationManagerAddress = + DELEGATION_MANAGER_CONTRACT_ADDRESS as `0x${string}`; + + const disableCalldata = DelegationManager.encode.disableDelegation({ + delegation: delegationToCancel, + }); + + const scope = { + type: "functionCall" as const, + targets: [delegationManagerAddress], + selectors: [disableCalldata.slice(0, 10) as `0x${string}`], + }; + const caveatBuilder = createCaveatBuilder(environment); + caveatBuilder.addCaveat("exactCalldata", { + calldata: disableCalldata, + }); + const caveats = caveatBuilder.build(); + const delegationWithoutSignature = createDelegation({ + to: delegateAddress, + from: mainAccountAddress, + environment, + scope, + caveats, + }); + + return ResultAsync.fromPromise( + signDelegation({ + privateKey: wallet.privateKey as `0x${string}`, + delegation: delegationWithoutSignature, + chainId: base.id, + delegationManager: environment.DelegationManager, + }), + (e) => e as Error, + ).map((signature) => { + const signedDelegation = { + ...delegationWithoutSignature, + signature, + }; + return { + delegate: signedDelegation.delegate, + delegator: signedDelegation.delegator, + authority: signedDelegation.authority, + caveats: signedDelegation.caveats.map((c) => ({ + enforcer: c.enforcer, + terms: c.terms, + args: c.args, + })), + salt: signedDelegation.salt, + signature: signedDelegation.signature, + } satisfies Delegation; + }); + }); + }); + }, + + authorizeSubAccountUpgrade: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + IAuthorizeSubAccountUpgradeParams, + IAuthorizeSubAccountUpgradeResponse + >(params, (p) => { + return walletFrame! + .getSubAccountPrivateKey(p.subAccountId) + .andThen((subAccountKey) => { + const account = privateKeyToAccount(subAccountKey as `0x${string}`); + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }); + return ResultAsync.fromPromise( + publicClient.getTransactionCount({ + address: account.address, + blockTag: "pending", + }), + (e) => e as Error, + ) + .andThen((nonce) => { + const walletClient = createWalletClient({ + account, + chain: base, + transport: http(), + }); + return ResultAsync.fromPromise( + signAuthorization(walletClient, { + contractAddress: + SUB_ACCOUNT_7702_CONTRACT_ADDRESS as `0x${string}`, + chainId: BASE_CHAIN_ID, + nonce, + }), + (e) => e as Error, + ); + }) + .map((authorization) => { + const signature = signatureComponentsToSignature( + authorization.r, + authorization.s, + authorization.yParity as 0 | 1, + ); + return { + address: EVMContractAddress(authorization.address), + nonce: authorization.nonce.toString(), + chainId: authorization.chainId.toString(), + signature: Signature(signature), + } satisfies IAuthorizeSubAccountUpgradeResponse; + }); + }); + }); + }, + + getAddSignerSignature: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + IGetAddSignerSignatureParams, + IGetAddSignerSignatureResponse + >(params, (p) => { + return walletFrame! + .getSubAccountPrivateKey(p.subAccountId) + .andThen((subAccountKey) => { + const account = privateKeyToAccount(subAccountKey as `0x${string}`); + const nonce = BigInt(Math.floor(Math.random() * 2 ** 32)); // Random nonce, no chain read + + console.debug("Adding signer hash parameters", [ + ADD_SIGNER_TYPE, + getAddress(p.signerAddress), + nonce, + BigInt(BASE_CHAIN_ID), + ]); + + const encoded = encodeAbiParameters( + parseAbiParameters("string, address, uint256, uint256"), + [ + ADD_SIGNER_TYPE, + getAddress(p.signerAddress), + nonce, + BigInt(BASE_CHAIN_ID), + ], + ); + const hash = keccak256(encoded); + + return ResultAsync.fromPromise( + account.signMessage({ + message: { raw: hash as Hex }, + }), + (e) => e as Error, + ).map(async (signature) => { + // Attempt to recover the signer address from the signature + const recoveredAddressMatches = await verifyMessage({ + address: account.address, + message: { raw: hash as Hex }, + signature: signature as `0x${string}`, + }); + + console.log( + `Signer account = ${account.address}, recovered address matches ${recoveredAddressMatches}`, + ); + return { + signature: Signature(signature), + nonce: BigNumberString(nonce.toString()), + } satisfies IGetAddSignerSignatureResponse; + }); + }); + }); + }, + + getSubAccountAddress: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper< + IGetSubAccountAddressParams, + IGetSubAccountAddressResponse + >(params, (p) => { + return walletFrame! + .getSubAccountPrivateKey(p.subAccountId) + .map((subAccountKey) => { + const account = privateKeyToAccount(subAccountKey as `0x${string}`); + return { + accountAddress: account.address as EVMAccountAddress, + } satisfies IGetSubAccountAddressResponse; + }); + }); + }, + + getAccountAddress: async (params: JSONString): Promise => { + if (!walletFrame) { + return; + } + walletFrame.rpcWrapper(params, () => { + const authResult = walletFrame!.getAuthResultSync(); + if (authResult == null) { + return errAsync(new Error("Not authenticated")); + } + + return okAsync({ + accountAddress: authResult!.user!.accountAddress, + } satisfies IGetAccountAddressResponse); + }); + }, + + displayPrivateKey: async (): Promise => { + if (!walletFrame) { + return; + } + await ResultAsync.combine([ + walletFrame.assureWallet(), + walletFrame.getInitialized(), + ]) + .andThen(([wallet, parent]) => { + console.log("Wallet unlocked, displaying private key"); + // Trigger the display of the private key in the component + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("displayPrivateKey", { + detail: { privateKey: wallet.privateKey }, + }), + ); + + // Listen for close event and emit to parent + const handleClose = () => { + parent!.emit("PrivateKeyDisplayClosed", ""); + window.removeEventListener( + "PrivateKeyDisplayClosed", + handleClose, + ); + }; + window.addEventListener("PrivateKeyDisplayClosed", handleClose); + } + return okAsync(undefined); + }) + .mapErr((error) => { + // If wallet is not available, emit error + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("displayPrivateKeyError", { + detail: { error: error.message }, + }), + ); + } + return error; + }); + }, + }); + + // Create WalletFrame instance with the Postmate model + walletFrame = new WalletFrame(postmateModel); + handshakePromise = Promise.resolve(postmateModel); +} + +// Initialize Postmate when module loads (only in browser) +if (typeof window !== "undefined") { + initializePostmate(); +} + +// Default export - Next.js requires a React component +// This page is loaded in an iframe and communicates via Postmate +export default function WalletPage() { + const [privateKey, setPrivateKey] = useState(null); + const [, setError] = useState(null); + + // Ensure Postmate is initialized (in case component renders before module init) + useEffect(() => { + if (typeof window !== "undefined" && handshakePromise === null) { + initializePostmate(); + } + + // Listen for displayPrivateKey events + const handleDisplayPrivateKey = ( + event: CustomEvent<{ privateKey: string }>, + ) => { + setPrivateKey(event.detail.privateKey); + setError(null); + }; + + const handleDisplayPrivateKeyError = ( + event: CustomEvent<{ error: string }>, + ) => { + setError(event.detail.error); + setPrivateKey(null); + }; + + window.addEventListener( + "displayPrivateKey" as keyof WindowEventMap, + handleDisplayPrivateKey as EventListener, + ); + window.addEventListener( + "displayPrivateKeyError" as keyof WindowEventMap, + handleDisplayPrivateKeyError as EventListener, + ); + + return () => { + window.removeEventListener( + "displayPrivateKey" as keyof WindowEventMap, + handleDisplayPrivateKey as EventListener, + ); + window.removeEventListener( + "displayPrivateKeyError" as keyof WindowEventMap, + handleDisplayPrivateKeyError as EventListener, + ); + }; + }, []); + + // If private key should be displayed, show it + if (privateKey) { + return ( + +
+
+

+ Your private key gives full control of this account. It's a unique + secret that proves ownership and allows you to access and move + your funds, including using this account in other wallets or apps + outside of 1Shot. Anyone who has this key can control the account. + 1ShotPay does not store your private key and cannot recover it if + it's lost or compromised. We will never ask for your private key. + Never share it with anyone. +

+
+ + + +
+ +
+
+ +
+ ); + } + + // Default: hidden iframe + return
; +} + +function completeAuthentication( + accountRecoveryId: AccountRecoveryId, + evmPrivateKey: EVMPrivateKey, + user: UserWithRecoveryDataModel, +): ResultAsync { + if (!walletFrame) { + return errAsync(new Error("WalletFrame not initialized")); + } + + const wallet = new ethers.Wallet(evmPrivateKey); + + console.log(`Account address: ${wallet.address}`); + + // Now that we have a wallet, we need to sign the recovery nonce + return ResultAsync.fromPromise(wallet.signMessage(user.loginNonce), (e) => { + return e as Error; + }).andThen((signature) => { + return walletFrame! + .fetchJson<{ success: boolean }>(`/api/auth/login/recover`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + accountRecoveryId: accountRecoveryId, + signature: signature, + }), + }) + .map(() => { + // Store username and user ID in localStorage (doesn't require React context) + if (typeof window !== "undefined") { + localStorage.setItem(USERNAME_KEY, user.username); + localStorage.setItem(USER_ID_KEY, user.id); + } + + // Cookie is set automatically by the server, no need to return token + const authResult: IFullAuthenticationResult = { + success: true, + walletUnlocked: true, + user: user, + wallet, + }; + walletFrame!.setAuthResult(authResult); + return authResult; + }); + }); +} diff --git a/packages/ows-signer/reference/walletFrame.ts b/packages/ows-signer/reference/walletFrame.ts new file mode 100644 index 0000000..12ef794 --- /dev/null +++ b/packages/ows-signer/reference/walletFrame.ts @@ -0,0 +1,626 @@ +/** + * WalletFrame - Core wallet functionality shared between wallet pages + * This class encapsulates common wallet operations and state management + * to reduce code duplication and improve maintainability. + */ + +import { EVMAccountAddress, Signature } from "@1shotapi/1shotpay-common"; +import { Delegation } from "@metamask/smart-accounts-kit"; +import { + startAuthentication, + PublicKeyCredentialRequestOptionsJSON, +} from "@simplewebauthn/browser"; +import { ethers, Wallet } from "ethers"; +import { errAsync, okAsync, ResultAsync } from "neverthrow"; +import { ResultUtils } from "neverthrow-result-utils"; +import Postmate from "postmate"; + +import { + deriveEVMPrivateKeyFromSignature, + getERC3009Signature, + normalizePrfOutputToArrayBuffer, + prfToValidEthPrivKey, + stringToHexString, +} from "@/clientUtils/ClientCrypto"; +import { + IAuthenticationResult, + IGetSubscriptionParams, + IRPCWrapperParams, + IRPCWrapperReturn, + rpcCallbackEventName, +} from "@/clientUtils/ProxyTypes"; +import { USER_ID_KEY, USERNAME_KEY } from "@/constants"; +import { ObjectUtils } from "@/implementations/utils/ObjectUtils"; +import { ISignedERC3009TransferWithAuthorization } from "@/interfaces/types/domain"; +import { UserModel } from "@/interfaces/types/models"; +import { + BigNumberString, + EVMPrivateKey, + JSONString, + SubAccountId, + UnixTimestamp, + USDCAmount, +} from "@/interfaces/types/primitives"; + +// Narrow clientExtensionResults for PRF reads without extending DOM types (extension +// results use `BufferSource`; Chrome may still surface shapes that need normalization). +type AuthenticationResultsWithPrf = { + prf?: { + results?: { + first?: unknown; + }; + }; +}; + +function getPrfOutputShapeDebugInfo( + prfOutput: unknown, +): Record { + const constructorName = + prfOutput !== null && + prfOutput !== undefined && + typeof prfOutput === "object" && + "constructor" in prfOutput && + typeof prfOutput.constructor === "function" + ? prfOutput.constructor.name + : null; + + let byteLength: number | null = null; + if (prfOutput instanceof ArrayBuffer) { + byteLength = prfOutput.byteLength; + } else if (ArrayBuffer.isView(prfOutput)) { + byteLength = prfOutput.byteLength; + } else if (Array.isArray(prfOutput)) { + byteLength = prfOutput.length; + } + + return { + type: typeof prfOutput, + constructorName, + isArrayBuffer: prfOutput instanceof ArrayBuffer, + isArrayBufferView: ArrayBuffer.isView(prfOutput), + isArray: Array.isArray(prfOutput), + byteLength, + stringLength: typeof prfOutput === "string" ? prfOutput.length : null, + }; +} + +// Use intersection type to avoid extension conflicts +type IExtendedAuthenticationExtensionsClientInputs = + AuthenticationExtensionsClientInputs & { + prf?: { + eval: { + first: Uint8Array; + }; + }; + }; + +// Use intersection type instead of extends to avoid type conflicts +type IExtendedPublicKeyCredentialRequestOptionsJSON = + PublicKeyCredentialRequestOptionsJSON & { + challengeId: string; + extensions: IExtendedAuthenticationExtensionsClientInputs; + }; + +export interface IFullAuthenticationResult extends IAuthenticationResult { + wallet?: Wallet; +} + +export class WalletFrame { + // Private state - kept in closure for security + private authResult: IFullAuthenticationResult | null = null; + private ceremonyResult: ResultAsync | null = + null; + private initialized: ResultAsync | null = null; + + constructor(readonly postmateModel: Postmate.Model) { + // Create ResultAsync wrapper for the handshake promise + this.initialized = ResultAsync.fromPromise( + Promise.resolve(postmateModel), + (e) => { + return e as Error; + }, + ); + } + + /** + * Get the initialized Postmate ChildAPI + */ + public getInitialized(): ResultAsync { + if (this.initialized === null) { + throw new Error( + "Postmate not initialized - this should only happen in browser", + ); + } + return this.initialized; + } + + /** + * Get the current authentication result + * If no authResult exists, checks session status via getStatus() + * Returns ResultAsync to allow async session checking + */ + public getAuthResult(): ResultAsync { + // If we already have an authResult, return it immediately + if (this.authResult !== null) { + return okAsync(this.authResult); + } + + // No authResult exists - check session status + return this.getStatus().map((statusResult) => { + // Convert IAuthenticationResult to IFullAuthenticationResult + const fullResult: IFullAuthenticationResult = { + success: statusResult.success, + walletUnlocked: statusResult.walletUnlocked || false, + user: statusResult.user, + error: statusResult.error, + canRetry: statusResult.canRetry, + wallet: undefined, // Wallet not unlocked yet + }; + // Store the result for future calls + this.authResult = fullResult; + return fullResult; + }); + } + + /** + * Get the current authentication result synchronously (for internal use) + * This is used when we know the authResult is already set + */ + public getAuthResultSync(): IFullAuthenticationResult | null { + return this.authResult; + } + + /** + * Check session status by calling /api/user endpoint + * This is the common implementation used by both WalletProxy and IntegrationWalletProxy + */ + public getStatus(): ResultAsync { + return this.fetchJson<{ session: boolean; user?: UserModel }>("/api/user") + .map((sessionData) => { + // If session is valid and user exists, return success + if (sessionData.session && sessionData.user) { + const authResult: IAuthenticationResult = { + success: true, + user: sessionData.user, + walletUnlocked: false, + }; + // Update internal authResult state + this.authResult = { + success: true, + walletUnlocked: false, + user: sessionData.user, + wallet: undefined, + }; + return authResult; + } else { + // No valid session + return { + success: false, + walletUnlocked: false, + error: "No valid session", + } satisfies IAuthenticationResult; + } + }) + .orElse((error) => { + return okAsync({ + success: false, + walletUnlocked: false, + error: error.message || "Failed to check session status", + } satisfies IAuthenticationResult); + }); + } + + /** + * Set the authentication result + */ + public setAuthResult(authResult: IFullAuthenticationResult | null): void { + this.authResult = authResult; + } + + /** + * Clear the authentication result + */ + public clearAuthResult(): void { + this.authResult = null; + } + + /** + * Look up user by account address (1Shot Pay username and profile if registered). + * Used by the ERC-3009 confirmation UI to show username and avatar instead of raw address. + */ + public getUserDisplayByAccountAddress( + accountAddress: EVMAccountAddress, + ): ResultAsync< + { userName: string | null; profileImageUrl: string | null }, + Error + > { + const url = `/api/user/accountAddress?accountAddress=${encodeURIComponent(accountAddress)}`; + return this.fetchJson<{ + isUser: boolean; + username: string | null; + profileImageUrl: string | null; + }>(url) + .map((data) => ({ + userName: data.isUser && data.username ? data.username : null, + profileImageUrl: data.profileImageUrl ?? null, + })) + .orElse(() => okAsync({ userName: null, profileImageUrl: null })); + } + + public noteExternalActivity(): ResultAsync { + return this.fetchJson<{ message: string }>("/api/user/ledger/external", { + method: "GET", + }).map(() => { + return undefined; + }); + } + + /** + * Fetch JSON from a URL with automatic cookie handling + */ + public fetchJson( + url: string, + options: RequestInit = {}, + ): ResultAsync { + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + // Cookies are automatically included, no need for Authorization header + + return ResultAsync.fromPromise( + fetch(url, { + ...options, + headers, + credentials: "include", // Include cookies + }), + (e) => { + return e as Error; + }, + ).andThen((response) => { + if (response.ok) { + return ResultAsync.fromPromise(response.json(), (e) => { + return e as Error; + }).map((json) => { + return json as T; + }); + } + return errAsync(new Error(`Failed to fetch JSON from ${url}`)); + }); + } + + /** + * Assure wallet is unlocked, triggering passkey ceremony if needed + */ + public assureWallet(): ResultAsync { + // Use synchronous getter since we need to check immediately + const currentAuthResult = this.getAuthResultSync(); + if (currentAuthResult == null || currentAuthResult.user == null) { + return errAsync(new Error("Not authenticated")); + } + + // Check if the wallet is unlocked! + if ( + currentAuthResult.walletUnlocked == false || + currentAuthResult.wallet == null + ) { + // We need to do a passkey ceremony + return this.authenticateWithPasskey( + currentAuthResult.user!.username!, + ).andThen((newAuthResult) => { + if (newAuthResult.success) { + this.authResult = newAuthResult; + return okAsync(newAuthResult.wallet!); + } + return errAsync(new Error(newAuthResult.error)); + }); + } + + return okAsync(currentAuthResult.wallet); + } + + /** + * Authenticate a user with their passkey and derive their EVM private key + * This function handles the complete authentication flow: + * 1. Get authentication options from server + * 2. Start authentication with PRF extension + * 3. Derive EVM private key from PRF output + * 4. Verify authentication with server + * 5. Return user, token, and wallet + * + * @param username - The username to authenticate + * @returns ResultAsync that resolves to AuthenticationResult with user, token, wallet, and privateKey + */ + public authenticateWithPasskey( + username: string, + ): ResultAsync { + if (this.ceremonyResult != null) { + console.log("Passkey authentication ceremony in progress"); + return this.ceremonyResult; + } + // Step 1: Get authentication options with username + console.log("Beginning passkey authentication ceremony"); + this.ceremonyResult = ResultUtils.backoffAndRetry( + () => { + return this.fetchJson( + "/api/auth/login", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username }), + }, + ); + }, + [Error], + 3, + 2, + ) + .map((authOptions) => { + return { + authOptions: authOptions, + errorReturn: null as IFullAuthenticationResult | null, + }; + }) + .orElse(() => { + console.warn( + `Failed to get authentcation options for username ${username}`, + ); + return okAsync({ + authOptions: + null as IExtendedPublicKeyCredentialRequestOptionsJSON | null, + errorReturn: { + success: false, + walletUnlocked: false, + error: `Username ${username} not found. Please check your username and try again.`, + canRetry: true, + } satisfies IFullAuthenticationResult, + }); + }) + .andThen((authOptionsOrError) => { + // The .orElse chain here is inordinately difficult to deal with. We might need to early abort. + if (authOptionsOrError.errorReturn != null) { + return okAsync(authOptionsOrError.errorReturn); + } + + const authOptions = authOptionsOrError.authOptions!; + // Add the PRF extension to the auth options + const infoLabel = new TextEncoder().encode("com.example.eth-key-v1"); + authOptions.extensions = { + prf: { + eval: { + first: infoLabel, + }, + }, + }; + + // Step 2: Start authentication on the client + return ResultAsync.fromPromise( + startAuthentication({ + optionsJSON: authOptions, + }), + (e) => { + console.error("Error authenticating with passkey"); + console.error(e); + return e as Error; + }, + ) + .andThen((credential) => { + // Extract PRF output and derive EVM private key + let normalizedPrfOutput: ArrayBuffer | null = null; + try { + const rawPrfOutput = ( + credential.clientExtensionResults as AuthenticationResultsWithPrf + ).prf?.results?.first; + normalizedPrfOutput = + normalizePrfOutputToArrayBuffer(rawPrfOutput); + if (normalizedPrfOutput == null) { + console.warn( + "Passkey PRF output has unsupported shape", + getPrfOutputShapeDebugInfo(rawPrfOutput), + ); + } + } catch { + return okAsync({ + success: false, + walletUnlocked: false, + error: + "Passkey does not have PRF information, authentication failed. Make sure you are using the same passkey provider you registered with.", + canRetry: false, + } satisfies IFullAuthenticationResult); + } + + if (normalizedPrfOutput == null) { + return okAsync({ + success: false, + walletUnlocked: false, + error: + "Passkey does not have PRF information, authentication failed. Make sure you are using the same passkey provider you registered with.", + canRetry: false, + } satisfies IFullAuthenticationResult); + } + + return ResultAsync.fromPromise( + prfToValidEthPrivKey(normalizedPrfOutput, infoLabel), + (e) => { + return e as Error; + }, + ).andThen((privateKey) => { + const wallet = new ethers.Wallet(privateKey); + + console.log( + `Successfully derived private key! Account address: ${wallet.address}`, + ); + + // Step 3: Verify authentication with server + return this.fetchJson<{ user: UserModel }>("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + credential, + challengeId: authOptions.challengeId, + accountAddress: wallet.address, + }), + }).map((result) => { + // Store username and user ID in localStorage (doesn't require React context) + if (typeof window !== "undefined") { + localStorage.setItem(USERNAME_KEY, result.user.username); + localStorage.setItem(USER_ID_KEY, result.user.id); + } + + // Cookie is set automatically by the server, no need to return token + this.authResult = { + success: true, + walletUnlocked: true, + user: result.user, + wallet, + } satisfies IFullAuthenticationResult; + return this.authResult; + }); + }); + }) + .orElse((e) => { + // If we get here, it means the authentication failed, probably because the user canceled. + console.error("Authentication failed", e); + + return okAsync({ + success: false, + walletUnlocked: false, + error: + "Passkey authentication cancelled or timed out. Make sure you are in a native browser and not in an in-app web view, as passkey authenticatoin may be restricted.", + canRetry: true, + } satisfies IFullAuthenticationResult); + }); + }) + .map((authResult) => { + // Clear the ceremonyResult so that we can retry + this.ceremonyResult = null; + return authResult; + }); + + return this.ceremonyResult; + } + + /** + * RPC wrapper for Postmate communication + * Handles serialization, deserialization, and event emission + */ + public async rpcWrapper( + paramString: JSONString, + callback: (params: T) => ResultAsync, + ): Promise { + // First we deserialize the request, which will have a nonce + await this.getInitialized().andThen((parent) => { + return ObjectUtils.deserialize>(paramString).andThen( + (params) => { + console.debug( + `Processing RPC request for nonce ${params.callbackNonce}`, + ); + // Then, we do the work + return callback(params.params) + .map((returnResult) => { + // Now we have a result to return. We need to emit an event + console.debug( + `Emitting return RPC callback for nonce ${params.callbackNonce}`, + returnResult, + ); + parent.emit( + rpcCallbackEventName, + ObjectUtils.serialize({ + success: true, + callbackNonce: params.callbackNonce, + result: ObjectUtils.serialize(returnResult), + } satisfies IRPCWrapperReturn), + ); + }) + .mapErr((e) => { + // An error is just a different emit + parent.emit( + rpcCallbackEventName, + ObjectUtils.serialize({ + success: false, + callbackNonce: params.callbackNonce, + result: ObjectUtils.serialize(e), + } satisfies IRPCWrapperReturn), + ); + + return e; + }); + }, + ); + }); + } + + /** + * Get ERC3009 signature (common implementation without confirmation) + * This can be overridden by pages that need confirmation UI + */ + public getERC3009Signature( + destinationAddress: EVMAccountAddress, + amount: BigNumberString, + validUntil: UnixTimestamp, + validAfter: UnixTimestamp, + ): ResultAsync { + return this.assureWallet().andThen((wallet) => { + return getERC3009Signature( + wallet, + destinationAddress, + amount, + validUntil, + validAfter, + ); + }); + } + + /** + * Persist a signed subscription (delegation) via POST /api/subscriptions. + * Uses fetchJson so the request is authenticated (session cookie). + */ + public createSubscription( + delegation: Delegation, + params: IGetSubscriptionParams, + minBalance?: USDCAmount, + ): ResultAsync { + const body = { + name: params.name, + description: params.description, + delegateAccountAddress: params.destinationAccountAddress, + delegation: JSON.stringify(delegation), + amountPerDay: params.amountPerDay, + amountPerWeek: params.amountPerWeek, + amountPerMonth: params.amountPerMonth, + minBalance: minBalance, + }; + return this.fetchJson<{ result: { subscription: unknown } }>( + "/api/subscriptions", + { + method: "POST", + body: JSON.stringify(body), + }, + ).map(() => undefined); + } + + /** + * Derive the sub-account EVM private key from the main account. + * Signs subAccountId with the main wallet and derives a key via PBKDF2. + */ + public getSubAccountPrivateKey( + subAccountId: SubAccountId, + ): ResultAsync { + return this.assureWallet() + .andThen((wallet) => { + return ResultAsync.fromPromise( + wallet.signMessage(subAccountId), + (e) => e as Error, + ); + }) + .andThen((signatureHex) => { + const salt = stringToHexString(subAccountId); + return deriveEVMPrivateKeyFromSignature( + Signature(signatureHex as `0x${string}`), + salt, + ).mapErr((e) => e as Error); + }); + } +} diff --git a/packages/ows-signer/src/constants.js b/packages/ows-signer/src/constants.js new file mode 100644 index 0000000..981554a --- /dev/null +++ b/packages/ows-signer/src/constants.js @@ -0,0 +1,33 @@ +/** @typedef {'secp256k1-ecdsa' | 'secp256k1-ecdsa-recoverable' | 'secp256k1-bip340' | 'ed25519'} SignScheme */ + +export const API_VERSION = 1; +export const SIGNER_VERSION = "0.1.0"; + +export const PRF_LABEL_SECP256K1 = new TextEncoder().encode("ows-v1/secp256k1"); +/** HKDF info for Ed25519 — IKM is the secp256k1 scalar (`signDigest` material). */ +export const PRF_LABEL_ED25519 = new TextEncoder().encode("ows-v1/ed25519"); +/** HKDF info for AES-256-GCM — IKM is the secp256k1 scalar (`signDigest` material). */ +export const PRF_LABEL_AES256 = new TextEncoder().encode("ows-v1/aes256-gcm"); + +/** @type {SignScheme[]} */ +export const SIGN_SCHEMES = [ + "secp256k1-ecdsa", + "secp256k1-ecdsa-recoverable", + "secp256k1-bip340", + "ed25519", +]; + +export const METHODS = [ + "getVersion", + "createCredential", + "signDigest", + "revealPrivateKey", + "importPrivateKey", + "createRecoveryData", + "recoverKey", + "getPublicKey", + "clearRecoverySession", + "encryptAES256", + "decryptAES256", + "executeBatch", +]; diff --git a/packages/ows-signer/src/crypto/aes256.js b/packages/ows-signer/src/crypto/aes256.js new file mode 100644 index 0000000..5df1c48 --- /dev/null +++ b/packages/ows-signer/src/crypto/aes256.js @@ -0,0 +1,115 @@ +import { PRF_LABEL_AES256 } from "../constants.js"; +import { parse0xHex, to0xHex } from "../hex.js"; +import { hkdfExpand } from "./prf.js"; + +/** Envelope prefix — distinct from recovery `ows1:` (PBKDF2). */ +export const AES256_ENVELOPE_PREFIX = "ows-aes1:"; +const AES256_VERSION = 1; +const IV_LENGTH = 12; + +/** + * Derive an AES-256-GCM CryptoKey from the wallet secp256k1 scalar + * (same material `signDigest` uses), via HKDF domain separation. + * + * @param {Uint8Array} secp256k1PrivateKey + * @returns {Promise} + */ +export async function deriveAes256KeyFromSecp256k1(secp256k1PrivateKey) { + const raw = await hkdfExpand( + secp256k1PrivateKey.buffer.slice( + secp256k1PrivateKey.byteOffset, + secp256k1PrivateKey.byteOffset + secp256k1PrivateKey.byteLength, + ), + PRF_LABEL_AES256, + 32, + ); + try { + return await crypto.subtle.importKey( + "raw", + raw, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + } finally { + raw.fill(0); + } +} + +/** + * Encrypt a UTF-8 plaintext. Envelope: `ows-aes1:0x` ‖ version ‖ IV ‖ ciphertext+tag. + * + * @param {string} plaintext + * @param {CryptoKey} aesKey + * @returns {Promise} + */ +export async function encryptAes256String(plaintext, aesKey) { + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const encoded = new TextEncoder().encode(plaintext); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded), + ); + const payload = new Uint8Array(1 + IV_LENGTH + ciphertext.length); + payload[0] = AES256_VERSION; + payload.set(iv, 1); + payload.set(ciphertext, 1 + IV_LENGTH); + return `${AES256_ENVELOPE_PREFIX}${to0xHex(payload)}`; +} + +/** + * Decrypt an `ows-aes1:` envelope to a UTF-8 string. + * + * @param {string} envelope + * @param {CryptoKey} aesKey + * @returns {Promise} + */ +export async function decryptAes256String(envelope, aesKey) { + if (!envelope.startsWith(AES256_ENVELOPE_PREFIX)) { + throw new Error("invalidAes256Envelope"); + } + const bytes = parse0xHex(envelope.slice(AES256_ENVELOPE_PREFIX.length)); + if (bytes.length < 1 + IV_LENGTH + 16) { + throw new Error("invalidAes256Envelope"); + } + if (bytes[0] !== AES256_VERSION) { + throw new Error("unsupportedAes256Version"); + } + const iv = bytes.slice(1, 1 + IV_LENGTH); + const ciphertext = bytes.slice(1 + IV_LENGTH); + try { + const plain = new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext), + ); + return new TextDecoder().decode(plain); + } catch { + throw new Error("decryptionFailed"); + } +} + +/** + * @param {string[]} plaintexts + * @param {Uint8Array} secp256k1PrivateKey + * @returns {Promise} + */ +export async function encryptAes256Batch(plaintexts, secp256k1PrivateKey) { + const aesKey = await deriveAes256KeyFromSecp256k1(secp256k1PrivateKey); + const out = []; + for (const plaintext of plaintexts) { + out.push(await encryptAes256String(plaintext, aesKey)); + } + return out; +} + +/** + * @param {string[]} envelopes + * @param {Uint8Array} secp256k1PrivateKey + * @returns {Promise} + */ +export async function decryptAes256Batch(envelopes, secp256k1PrivateKey) { + const aesKey = await deriveAes256KeyFromSecp256k1(secp256k1PrivateKey); + const out = []; + for (const envelope of envelopes) { + out.push(await decryptAes256String(envelope, aesKey)); + } + return out; +} diff --git a/packages/ows-signer/src/crypto/prf.js b/packages/ows-signer/src/crypto/prf.js new file mode 100644 index 0000000..83c9ec1 --- /dev/null +++ b/packages/ows-signer/src/crypto/prf.js @@ -0,0 +1,136 @@ +import { PRF_LABEL_ED25519, PRF_LABEL_SECP256K1 } from "../constants.js"; +import { getPublicKeyAsync as edGetPublicKeyAsync } from "./vendor/noble-ed25519.js"; +import { getPublicKey as secpGetPublicKey } from "./vendor/noble-secp256k1.js"; + +const SECP256K1_N = BigInt( + "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", +); + +/** + * @param {unknown} prfOutput + * @returns {ArrayBuffer | null} + */ +export function normalizePrfOutputToArrayBuffer(prfOutput) { + if (prfOutput instanceof ArrayBuffer) return prfOutput; + if (prfOutput instanceof Uint8Array) { + return prfOutput.buffer.slice( + prfOutput.byteOffset, + prfOutput.byteOffset + prfOutput.byteLength, + ); + } + if (ArrayBuffer.isView(prfOutput)) { + const view = /** @type {ArrayBufferView} */ (prfOutput); + return view.buffer.slice( + view.byteOffset, + view.byteOffset + view.byteLength, + ); + } + if (Array.isArray(prfOutput)) { + return new Uint8Array(prfOutput).buffer; + } + return null; +} + +/** + * @param {ArrayBuffer} prfBuffer + * @param {Uint8Array} infoLabel + * @param {number} length + * @returns {Promise} + */ +export async function hkdfExpand(prfBuffer, infoLabel, length) { + const ikm = new Uint8Array(prfBuffer); + const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, [ + "deriveBits", + ]); + const salt = new Uint8Array(32); + const bits = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt, + info: infoLabel, + }, + baseKey, + length * 8, + ); + return new Uint8Array(bits); +} + +/** + * @param {ArrayBuffer} prfBuffer + * @param {Uint8Array} infoLabel + * @returns {Promise} + */ +export async function deriveScalarFromPrf(prfBuffer, infoLabel) { + for (let counter = 0; counter < 255; counter++) { + const info = new Uint8Array(infoLabel.length + 1); + info.set(infoLabel); + info[infoLabel.length] = counter; + const candidate = await hkdfExpand(prfBuffer, info, 32); + const scalar = bytesToBigInt(candidate); + if (scalar > 0n && scalar < SECP256K1_N) { + return candidate; + } + } + throw new Error("Failed to derive valid secp256k1 scalar from PRF"); +} + +/** + * HKDF info `ows-v1/ed25519` — IKM is the secp256k1 scalar (`signDigest` + * material), not raw PRF. Recovery / import only hold that scalar, so Ed25519 + * must chain through it or a recovery session would emit a different pubkey. + * + * @param {Uint8Array} secp256k1Scalar + * @returns {Promise} + */ +export async function deriveEd25519SeedFromSecp256k1Scalar(secp256k1Scalar) { + return hkdfExpand( + secp256k1Scalar.buffer.slice( + secp256k1Scalar.byteOffset, + secp256k1Scalar.byteOffset + secp256k1Scalar.byteLength, + ), + PRF_LABEL_ED25519, + 32, + ); +} + +/** + * @param {PublicKeyCredential} credential + * @returns {Promise<{ secp256k1PrivateKey: Uint8Array, secp256k1PublicKey: Uint8Array, ed25519Seed: Uint8Array, ed25519PublicKey: Uint8Array }>} + */ +export async function deriveKeysFromCredential(credential) { + const rawPrf = /** @type {{ prf?: { results?: { first?: unknown } } }} */ ( + credential.getClientExtensionResults() + ).prf?.results?.first; + const prfBuffer = normalizePrfOutputToArrayBuffer(rawPrf); + if (!prfBuffer) { + throw new Error("Passkey does not support PRF or PRF output missing"); + } + + const secp256k1PrivateKey = await deriveScalarFromPrf( + prfBuffer, + PRF_LABEL_SECP256K1, + ); + const ed25519Seed = + await deriveEd25519SeedFromSecp256k1Scalar(secp256k1PrivateKey); + // Uncompressed (0x04 ‖ X ‖ Y) — required by viem `publicKeyToAddress`. + const secp256k1PublicKey = secpGetPublicKey(secp256k1PrivateKey, false); + const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed); + + return { + secp256k1PrivateKey, + secp256k1PublicKey, + ed25519Seed, + ed25519PublicKey, + }; +} + +/** + * @param {Uint8Array} bytes + * @returns {bigint} + */ +function bytesToBigInt(bytes) { + let result = 0n; + for (const b of bytes) result = (result << 8n) + BigInt(b); + return result; +} diff --git a/packages/ows-signer/src/crypto/recovery.js b/packages/ows-signer/src/crypto/recovery.js new file mode 100644 index 0000000..d68f5c2 --- /dev/null +++ b/packages/ows-signer/src/crypto/recovery.js @@ -0,0 +1,81 @@ +import { parse0xHex, to0xHex } from "../hex.js"; + +const RECOVERY_VERSION = 1; +const PBKDF2_ITERATIONS = 250_000; + +/** + * @param {string} passphrase + * @param {Uint8Array} salt + * @returns {Promise} + */ +async function deriveAesKey(passphrase, salt) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + enc.encode(passphrase), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +/** + * @param {Uint8Array} privateKey + * @param {string} passphrase + * @returns {Promise} + */ +export async function encryptPrivateKey(privateKey, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const aesKey = await deriveAesKey(passphrase, salt); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, privateKey), + ); + const payload = new Uint8Array( + 1 + salt.length + iv.length + ciphertext.length, + ); + payload[0] = RECOVERY_VERSION; + payload.set(salt, 1); + payload.set(iv, 1 + salt.length); + payload.set(ciphertext, 1 + salt.length + iv.length); + return `ows1:${to0xHex(payload)}`; +} + +/** + * @param {string} envelope + * @returns {Promise} + */ +export async function decryptPrivateKey(envelope, passphrase) { + if (!envelope.startsWith("ows1:")) { + throw new Error("invalidRecoveryEnvelope"); + } + const bytes = parse0xHex(envelope.slice(5)); + if (bytes[0] !== RECOVERY_VERSION) { + throw new Error("unsupportedRecoveryVersion"); + } + const salt = bytes.slice(1, 17); + const iv = bytes.slice(17, 29); + const ciphertext = bytes.slice(29); + const aesKey = await deriveAesKey(passphrase, salt); + try { + const plain = new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext), + ); + if (plain.length !== 32) throw new Error("invalidDecryptedKey"); + return plain; + } catch { + throw new Error("decryptionFailed"); + } +} diff --git a/packages/ows-signer/src/crypto/sign.js b/packages/ows-signer/src/crypto/sign.js new file mode 100644 index 0000000..d7e981c --- /dev/null +++ b/packages/ows-signer/src/crypto/sign.js @@ -0,0 +1,61 @@ +import { to0xHex } from "../hex.js"; +import { signAsync as edSignAsync } from "./vendor/noble-ed25519.js"; +import { signAsync as secpSignAsync } from "./vendor/noble-secp256k1.js"; + +/** + * @param {import('../constants.js').SignScheme} scheme + * @param {Uint8Array} digest + * @param {Uint8Array} secp256k1PrivateKey + * @param {Uint8Array} ed25519Seed + * @returns {Promise<`0x${string}`>} + */ +export async function signWithScheme( + scheme, + digest, + secp256k1PrivateKey, + ed25519Seed, +) { + switch (scheme) { + case "secp256k1-ecdsa": { + const sig = await secpSignAsync(digest, secp256k1PrivateKey, { + lowS: true, + }); + return to0xHex(sig.toCompactRawBytes()); + } + case "secp256k1-ecdsa-recoverable": { + const sig = await secpSignAsync(digest, secp256k1PrivateKey, { + lowS: true, + }); + const compact = sig.toCompactRawBytes(); + // Ethereum ecrecover / OpenZeppelin ECDSA require v ∈ {27, 28}. + // Noble returns recovery ∈ {0,1} (rarely 2,3); mask to yParity then +27. + const recovery = sig.recovery ?? 0; + const out = new Uint8Array(65); + out.set(compact, 0); + out[64] = 27 + (recovery & 1); + return to0xHex(out); + } + case "secp256k1-bip340": + throw new Error("schemeNotImplemented"); + case "ed25519": { + const sig = await edSignAsync(digest, ed25519Seed); + return to0xHex(sig); + } + default: + throw new Error("unknownScheme"); + } +} + +/** + * @param {import('../constants.js').SignScheme} scheme + * @param {Uint8Array} payload + */ +export function validateSignPayload(scheme, payload) { + if (scheme === "ed25519") { + if (payload.length === 0) throw new Error("invalidPayload"); + return; + } + if (payload.length !== 32) { + throw new Error("digestMustBe32Bytes"); + } +} diff --git a/packages/ows-signer/src/crypto/vendor/noble-ed25519.js b/packages/ows-signer/src/crypto/vendor/noble-ed25519.js new file mode 100644 index 0000000..ab45032 --- /dev/null +++ b/packages/ows-signer/src/crypto/vendor/noble-ed25519.js @@ -0,0 +1,385 @@ +/*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ +const P = 2n ** 255n - 19n; // ed25519 is twisted edwards curve +const N = 2n ** 252n + 27742317777372353535851937790883648493n; // curve's (group) order +const Gx = 0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an; // base point x +const Gy = 0x6666666666666666666666666666666666666666666666666666666666666658n; // base point y +const CURVE = { + a: -1n, // where a=-1, d = -(121665/121666) == -(121665 * inv(121666)) mod P + d: 37095705934669439343138083508754565189542113879843219016388785533085940283555n, + p: P, n: N, h: 8, Gx, Gy // field prime, curve (group) order, cofactor +}; +const err = (m = '') => { throw new Error(m); }; // error helper, messes-up stack trace +const str = (s) => typeof s === 'string'; // is string +const isu8 = (a) => (a instanceof Uint8Array || + (a != null && typeof a === 'object' && a.constructor.name === 'Uint8Array')); +const au8 = (a, l) => // is Uint8Array (of specific length) + !isu8(a) || (typeof l === 'number' && l > 0 && a.length !== l) ? + err('Uint8Array of valid length expected') : a; +const u8n = (data) => new Uint8Array(data); // creates Uint8Array +const toU8 = (a, len) => au8(str(a) ? h2b(a) : u8n(au8(a)), len); // norm(hex/u8a) to u8a +const mod = (a, b = P) => { let r = a % b; return r >= 0n ? r : b + r; }; // mod division +const isPoint = (p) => (p instanceof Point ? p : err('Point expected')); // is xyzt point +class Point { + constructor(ex, ey, ez, et) { + this.ex = ex; + this.ey = ey; + this.ez = ez; + this.et = et; + } + static fromAffine(p) { return new Point(p.x, p.y, 1n, mod(p.x * p.y)); } + static fromHex(hex, zip215 = false) { + const { d } = CURVE; + hex = toU8(hex, 32); + const normed = hex.slice(); // copy the array to not mess it up + const lastByte = hex[31]; + normed[31] = lastByte & ~0x80; // adjust first LE byte = last BE byte + const y = b2n_LE(normed); // decode as little-endian, convert to num + if (zip215 && !(0n <= y && y < 2n ** 256n)) + err('bad y coord 1'); // zip215=true [1..2^256-1] + if (!zip215 && !(0n <= y && y < P)) + err('bad y coord 2'); // zip215=false [1..P-1] + const y2 = mod(y * y); // y² + const u = mod(y2 - 1n); // u=y²-1 + const v = mod(d * y2 + 1n); // v=dy²+1 + let { isValid, value: x } = uvRatio(u, v); // (uv³)(uv⁷)^(p-5)/8; square root + if (!isValid) + err('bad y coordinate 3'); // not square root: bad point + const isXOdd = (x & 1n) === 1n; // adjust sign of x coordinate + const isLastByteOdd = (lastByte & 0x80) !== 0; // x_0, last bit + if (!zip215 && x === 0n && isLastByteOdd) + err('bad y coord 3'); // x=0 and x_0 = 1 + if (isLastByteOdd !== isXOdd) + x = mod(-x); + return new Point(x, y, 1n, mod(x * y)); // Z=1, T=xy + } + get x() { return this.toAffine().x; } // .x, .y will call expensive toAffine. + get y() { return this.toAffine().y; } // Should be used with care. + equals(other) { + const { ex: X1, ey: Y1, ez: Z1 } = this; + const { ex: X2, ey: Y2, ez: Z2 } = isPoint(other); // isPoint() checks class equality + const X1Z2 = mod(X1 * Z2), X2Z1 = mod(X2 * Z1); + const Y1Z2 = mod(Y1 * Z2), Y2Z1 = mod(Y2 * Z1); + return X1Z2 === X2Z1 && Y1Z2 === Y2Z1; + } + is0() { return this.equals(I); } + negate() { + return new Point(mod(-this.ex), this.ey, this.ez, mod(-this.et)); + } + double() { + const { ex: X1, ey: Y1, ez: Z1 } = this; // Cost: 4M + 4S + 1*a + 6add + 1*2 + const { a } = CURVE; // https://hyperelliptic.org/EFD/g1p/auto-twisted-extended.html#doubling-dbl-2008-hwcd + const A = mod(X1 * X1); + const B = mod(Y1 * Y1); + const C = mod(2n * mod(Z1 * Z1)); + const D = mod(a * A); + const x1y1 = X1 + Y1; + const E = mod(mod(x1y1 * x1y1) - A - B); + const G = D + B; + const F = G - C; + const H = D - B; + const X3 = mod(E * F); + const Y3 = mod(G * H); + const T3 = mod(E * H); + const Z3 = mod(F * G); + return new Point(X3, Y3, Z3, T3); + } + add(other) { + const { ex: X1, ey: Y1, ez: Z1, et: T1 } = this; // Cost: 8M + 1*k + 8add + 1*2. + const { ex: X2, ey: Y2, ez: Z2, et: T2 } = isPoint(other); // doesn't check if other on-curve + const { a, d } = CURVE; // http://hyperelliptic.org/EFD/g1p/auto-twisted-extended-1.html#addition-add-2008-hwcd-3 + const A = mod(X1 * X2); + const B = mod(Y1 * Y2); + const C = mod(T1 * d * T2); + const D = mod(Z1 * Z2); + const E = mod((X1 + Y1) * (X2 + Y2) - A - B); + const F = mod(D - C); + const G = mod(D + C); + const H = mod(B - a * A); + const X3 = mod(E * F); + const Y3 = mod(G * H); + const T3 = mod(E * H); + const Z3 = mod(F * G); + return new Point(X3, Y3, Z3, T3); + } + mul(n, safe = true) { + if (n === 0n) + return safe === true ? err('cannot multiply by 0') : I; + if (!(typeof n === 'bigint' && 0n < n && n < N)) + err('invalid scalar, must be < L'); + if (!safe && this.is0() || n === 1n) + return this; // safe=true bans 0. safe=false allows 0. + if (this.equals(G)) + return wNAF(n).p; // use wNAF precomputes for base points + let p = I, f = G; // init result point & fake point + for (let d = this; n > 0n; d = d.double(), n >>= 1n) { // double-and-add ladder + if (n & 1n) + p = p.add(d); // if bit is present, add to point + else if (safe) + f = f.add(d); // if not, add to fake for timing safety + } + return p; + } + multiply(scalar) { return this.mul(scalar); } // Aliases for compatibilty + clearCofactor() { return this.mul(BigInt(CURVE.h), false); } // multiply by cofactor + isSmallOrder() { return this.clearCofactor().is0(); } // check if P is small order + isTorsionFree() { + let p = this.mul(N / 2n, false).double(); // ensures the point is not "bad". + if (N % 2n) + p = p.add(this); // P^(N+1) // P*N == (P*(N/2))*2+P + return p.is0(); + } + toAffine() { + const { ex: x, ey: y, ez: z } = this; // (x, y, z, t) ∋ (x=x/z, y=y/z, t=xy) + if (this.equals(I)) + return { x: 0n, y: 1n }; // fast-path for zero point + const iz = invert(z); // z^-1: invert z + if (mod(z * iz) !== 1n) + err('invalid inverse'); // (z * z^-1) must be 1, otherwise bad math + return { x: mod(x * iz), y: mod(y * iz) }; // x = x*z^-1; y = y*z^-1 + } + toRawBytes() { + const { x, y } = this.toAffine(); // convert to affine 2d point + const b = n2b_32LE(y); // encode number to 32 bytes + b[31] |= x & 1n ? 0x80 : 0; // store sign in first LE byte + return b; + } + toHex() { return b2h(this.toRawBytes()); } // encode to hex string +} +Point.BASE = new Point(Gx, Gy, 1n, mod(Gx * Gy)); // Generator / Base point +Point.ZERO = new Point(0n, 1n, 1n, 0n); // Identity / Zero point +const { BASE: G, ZERO: I } = Point; // Generator, identity points +const padh = (num, pad) => num.toString(16).padStart(pad, '0'); +const b2h = (b) => Array.from(b).map(e => padh(e, 2)).join(''); // bytes to hex +const h2b = (hex) => { + const l = hex.length; // error if not string, + if (!str(hex) || l % 2) + err('hex invalid 1'); // or has odd length like 3, 5. + const arr = u8n(l / 2); // create result array + for (let i = 0; i < arr.length; i++) { + const j = i * 2; + const h = hex.slice(j, j + 2); // hexByte. slice is faster than substr + const b = Number.parseInt(h, 16); // byte, created from string part + if (Number.isNaN(b) || b < 0) + err('hex invalid 2'); // byte must be valid 0 <= byte < 256 + arr[i] = b; + } + return arr; +}; +const n2b_32LE = (num) => h2b(padh(num, 32 * 2)).reverse(); // number to bytes LE +const b2n_LE = (b) => BigInt('0x' + b2h(u8n(au8(b)).reverse())); // bytes LE to num +const concatB = (...arrs) => { + const r = u8n(arrs.reduce((sum, a) => sum + au8(a).length, 0)); // create u8a of summed length + let pad = 0; // walk through each array, + arrs.forEach(a => { r.set(a, pad); pad += a.length; }); // ensure they have proper type + return r; +}; +const invert = (num, md = P) => { + if (num === 0n || md <= 0n) + err('no inverse n=' + num + ' mod=' + md); // no neg exponent for now + let a = mod(num, md), b = md, x = 0n, y = 1n, u = 1n, v = 0n; + while (a !== 0n) { // uses euclidean gcd algorithm + const q = b / a, r = b % a; // not constant-time + const m = x - u * q, n = y - v * q; + b = a, a = r, x = u, y = v, u = m, v = n; + } + return b === 1n ? mod(x, md) : err('no inverse'); // b is gcd at this point +}; +const pow2 = (x, power) => { + let r = x; + while (power-- > 0n) { + r *= r; + r %= P; + } + return r; +}; +const pow_2_252_3 = (x) => { + const x2 = (x * x) % P; // x^2, bits 1 + const b2 = (x2 * x) % P; // x^3, bits 11 + const b4 = (pow2(b2, 2n) * b2) % P; // x^(2^4-1), bits 1111 + const b5 = (pow2(b4, 1n) * x) % P; // x^(2^5-1), bits 11111 + const b10 = (pow2(b5, 5n) * b5) % P; // x^(2^10) + const b20 = (pow2(b10, 10n) * b10) % P; // x^(2^20) + const b40 = (pow2(b20, 20n) * b20) % P; // x^(2^40) + const b80 = (pow2(b40, 40n) * b40) % P; // x^(2^80) + const b160 = (pow2(b80, 80n) * b80) % P; // x^(2^160) + const b240 = (pow2(b160, 80n) * b80) % P; // x^(2^240) + const b250 = (pow2(b240, 10n) * b10) % P; // x^(2^250) + const pow_p_5_8 = (pow2(b250, 2n) * x) % P; // < To pow to (p+3)/8, multiply it by x. + return { pow_p_5_8, b2 }; +}; +const RM1 = 19681161376707505956807079304988542015446066515923890162744021073123829784752n; // √-1 +const uvRatio = (u, v) => { + const v3 = mod(v * v * v); // v³ + const v7 = mod(v3 * v3 * v); // v⁷ + const pow = pow_2_252_3(u * v7).pow_p_5_8; // (uv⁷)^(p-5)/8 + let x = mod(u * v3 * pow); // (uv³)(uv⁷)^(p-5)/8 + const vx2 = mod(v * x * x); // vx² + const root1 = x; // First root candidate + const root2 = mod(x * RM1); // Second root candidate; RM1 is √-1 + const useRoot1 = vx2 === u; // If vx² = u (mod p), x is a square root + const useRoot2 = vx2 === mod(-u); // If vx² = -u, set x <-- x * 2^((p-1)/4) + const noRoot = vx2 === mod(-u * RM1); // There is no valid root, vx² = -u√-1 + if (useRoot1) + x = root1; + if (useRoot2 || noRoot) + x = root2; // We return root2 anyway, for const-time + if ((mod(x) & 1n) === 1n) + x = mod(-x); // edIsNegative + return { isValid: useRoot1 || useRoot2, value: x }; +}; +const modL_LE = (hash) => mod(b2n_LE(hash), N); // modulo L; but little-endian +let _shaS; +const sha512a = (...m) => etc.sha512Async(...m); // Async SHA512 +const sha512s = (...m) => // Sync SHA512, not set by default + typeof _shaS === 'function' ? _shaS(...m) : err('etc.sha512Sync not set'); +const hash2extK = (hashed) => { + const head = hashed.slice(0, 32); // slice creates a copy, unlike subarray + head[0] &= 248; // Clamp bits: 0b1111_1000, + head[31] &= 127; // 0b0111_1111, + head[31] |= 64; // 0b0100_0000 + const prefix = hashed.slice(32, 64); // private key "prefix" + const scalar = modL_LE(head); // modular division over curve order + const point = G.mul(scalar); // public key point + const pointBytes = point.toRawBytes(); // point serialized to Uint8Array + return { head, prefix, scalar, point, pointBytes }; +}; +// RFC8032 5.1.5; getPublicKey async, sync. Hash priv key and extract point. +const getExtendedPublicKeyAsync = (priv) => sha512a(toU8(priv, 32)).then(hash2extK); +const getExtendedPublicKey = (priv) => hash2extK(sha512s(toU8(priv, 32))); +const getPublicKeyAsync = (priv) => getExtendedPublicKeyAsync(priv).then(p => p.pointBytes); +const getPublicKey = (priv) => getExtendedPublicKey(priv).pointBytes; +function hashFinish(asynchronous, res) { + if (asynchronous) + return sha512a(res.hashable).then(res.finish); + return res.finish(sha512s(res.hashable)); +} +const _sign = (e, rBytes, msg) => { + const { pointBytes: P, scalar: s } = e; + const r = modL_LE(rBytes); // r was created outside, reduce it modulo L + const R = G.mul(r).toRawBytes(); // R = [r]B + const hashable = concatB(R, P, msg); // dom2(F, C) || R || A || PH(M) + const finish = (hashed) => { + const S = mod(r + modL_LE(hashed) * s, N); // S = (r + k * s) mod L; 0 <= s < l + return au8(concatB(R, n2b_32LE(S)), 64); // 64-byte sig: 32b R.x + 32b LE(S) + }; + return { hashable, finish }; +}; +const signAsync = async (msg, privKey) => { + const m = toU8(msg); // RFC8032 5.1.6: sign msg with key async + const e = await getExtendedPublicKeyAsync(privKey); // pub,prfx + const rBytes = await sha512a(e.prefix, m); // r = SHA512(dom2(F, C) || prefix || PH(M)) + return hashFinish(true, _sign(e, rBytes, m)); // gen R, k, S, then 64-byte signature +}; +const sign = (msg, privKey) => { + const m = toU8(msg); // RFC8032 5.1.6: sign msg with key sync + const e = getExtendedPublicKey(privKey); // pub,prfx + const rBytes = sha512s(e.prefix, m); // r = SHA512(dom2(F, C) || prefix || PH(M)) + return hashFinish(false, _sign(e, rBytes, m)); // gen R, k, S, then 64-byte signature +}; +const dvo = { zip215: true }; +const _verify = (sig, msg, pub, opts = dvo) => { + msg = toU8(msg); // Message hex str/Bytes + sig = toU8(sig, 64); // Signature hex str/Bytes, must be 64 bytes + const { zip215 } = opts; // switch between zip215 and rfc8032 verif + let A, R, s, SB, hashable = new Uint8Array(); + try { + A = Point.fromHex(pub, zip215); // public key A decoded + R = Point.fromHex(sig.slice(0, 32), zip215); // 0 <= R < 2^256: ZIP215 R can be >= P + s = b2n_LE(sig.slice(32, 64)); // Decode second half as an integer S + SB = G.mul(s, false); // in the range 0 <= s < L + hashable = concatB(R.toRawBytes(), A.toRawBytes(), msg); // dom2(F, C) || R || A || PH(M) + } + catch (error) { } + const finish = (hashed) => { + if (SB == null) + return false; // false if try-catch catched an error + if (!zip215 && A.isSmallOrder()) + return false; // false for SBS: Strongly Binding Signature + const k = modL_LE(hashed); // decode in little-endian, modulo L + const RkA = R.add(A.mul(k, false)); // [8]R + [8][k]A' + return RkA.add(SB.negate()).clearCofactor().is0(); // [8][S]B = [8]R + [8][k]A' + }; + return { hashable, finish }; +}; +// RFC8032 5.1.7: verification async, sync +const verifyAsync = async (s, m, p, opts = dvo) => hashFinish(true, _verify(s, m, p, opts)); +const verify = (s, m, p, opts = dvo) => hashFinish(false, _verify(s, m, p, opts)); +const cr = () => // We support: 1) browsers 2) node.js 19+ + typeof globalThis === 'object' && 'crypto' in globalThis ? globalThis.crypto : undefined; +const etc = { + bytesToHex: b2h, hexToBytes: h2b, concatBytes: concatB, + mod, invert, + randomBytes: (len = 32) => { + const crypto = cr(); // Can be shimmed in node.js <= 18 to prevent error: + // import { webcrypto } from 'node:crypto'; + // if (!globalThis.crypto) globalThis.crypto = webcrypto; + if (!crypto || !crypto.getRandomValues) + err('crypto.getRandomValues must be defined'); + return crypto.getRandomValues(u8n(len)); + }, + sha512Async: async (...messages) => { + const crypto = cr(); + if (!crypto || !crypto.subtle) + err('crypto.subtle or etc.sha512Async must be defined'); + const m = concatB(...messages); + return u8n(await crypto.subtle.digest('SHA-512', m.buffer)); + }, + sha512Sync: undefined, // Actual logic below +}; +Object.defineProperties(etc, { sha512Sync: { + configurable: false, get() { return _shaS; }, set(f) { if (!_shaS) + _shaS = f; }, + } }); +const utils = { + getExtendedPublicKeyAsync, getExtendedPublicKey, + randomPrivateKey: () => etc.randomBytes(32), + precompute(w = 8, p = G) { p.multiply(3n); w; return p; }, // no-op +}; +const W = 8; // Precomputes-related code. W = window size +const precompute = () => { + const points = []; // 10x sign(), 2x verify(). To achieve this, + const windows = 256 / W + 1; // app needs to spend 40ms+ to calculate + let p = G, b = p; // a lot of points related to base point G. + for (let w = 0; w < windows; w++) { // Points are stored in array and used + b = p; // any time Gx multiplication is done. + points.push(b); // They consume 16-32 MiB of RAM. + for (let i = 1; i < 2 ** (W - 1); i++) { + b = b.add(p); + points.push(b); + } + p = b.double(); // Precomputes don't speed-up getSharedKey, + } // which multiplies user point by scalar, + return points; // when precomputes are using base point +}; +let Gpows = undefined; // precomputes for base point G +const wNAF = (n) => { + // Compared to other point mult methods, + const comp = Gpows || (Gpows = precompute()); // stores 2x less points using subtraction + const neg = (cnd, p) => { let n = p.negate(); return cnd ? n : p; }; // negate + let p = I, f = G; // f must be G, or could become I in the end + const windows = 1 + 256 / W; // W=8 17 windows + const wsize = 2 ** (W - 1); // W=8 128 window size + const mask = BigInt(2 ** W - 1); // W=8 will create mask 0b11111111 + const maxNum = 2 ** W; // W=8 256 + const shiftBy = BigInt(W); // W=8 8 + for (let w = 0; w < windows; w++) { + const off = w * wsize; + let wbits = Number(n & mask); // extract W bits. + n >>= shiftBy; // shift number by W bits. + if (wbits > wsize) { + wbits -= maxNum; + n += 1n; + } // split if bits > max: +224 => 256-32 + const off1 = off, off2 = off + Math.abs(wbits) - 1; // offsets, evaluate both + const cnd1 = w % 2 !== 0, cnd2 = wbits < 0; // conditions, evaluate both + if (wbits === 0) { + f = f.add(neg(cnd1, comp[off1])); // bits are 0: add garbage to fake point + } + else { // ^ can't add off2, off2 = I + p = p.add(neg(cnd2, comp[off2])); // bits are 1: add to result point + } + } + return { p, f }; // return both real and fake points for JIT +}; // !! you can disable precomputes by commenting-out call of the wNAF() inside Point#mul() +export { getPublicKey, getPublicKeyAsync, sign, verify, // Remove the export to easily use in REPL +signAsync, verifyAsync, CURVE, etc, utils, Point as ExtendedPoint }; // envs like browser console diff --git a/packages/ows-signer/src/crypto/vendor/noble-secp256k1.js b/packages/ows-signer/src/crypto/vendor/noble-secp256k1.js new file mode 100644 index 0000000..e1156d4 --- /dev/null +++ b/packages/ows-signer/src/crypto/vendor/noble-secp256k1.js @@ -0,0 +1,602 @@ +/*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ +/** + * 4KB JS implementation of secp256k1 signatures & ECDH. Compliant with RFC6979. + * @module + */ +const B256 = 2n ** 256n; +const P = B256 - 0x1000003d1n; // curve's field prime +const N = B256 - 0x14551231950b75fc4402da1732fc9bebfn; // curve (group) order +const Gx = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; // base point x +const Gy = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; // base point y +/** + * secp256k1 curve parameters. Equation is x³ + ax + b. + * Gx and Gy are generator coordinates. p is field order, n is group order. + */ +const CURVE = { + p: P, n: N, a: 0n, b: 7n, Gx, Gy +}; // exported variables incl. a, b +const fLen = 32; // field / group byte length +const curve = (x) => M(M(x * x) * x + CURVE.b); // x³ + ax + b weierstrass formula; a=0 +const err = (m = '') => { throw new Error(m); }; // error helper, messes-up stack trace +const isB = (n) => typeof n === 'bigint'; // is big integer +const isS = (s) => typeof s === 'string'; // is string +const fe = (n) => isB(n) && 0n < n && n < P; // is field element (invertible) +const ge = (n) => isB(n) && 0n < n && n < N; // is group element +const isu8 = (a) => (a instanceof Uint8Array || (ArrayBuffer.isView(a) && a.constructor.name === 'Uint8Array')); +const au8 = (a, l) => // assert is Uint8Array (of specific length) + !isu8(a) || (typeof l === 'number' && l > 0 && a.length !== l) ? + err('Uint8Array expected') : a; +const u8n = (data) => new Uint8Array(data); // creates Uint8Array +const toU8 = (a, len) => au8(isS(a) ? h2b(a) : u8n(au8(a)), len); // norm(hex/u8a) to u8a +const M = (a, b = P) => { + const r = a % b; + return r >= 0n ? r : b + r; +}; +const aPoint = (p) => (p instanceof Point ? p : err('Point expected')); // is 3d point +/** Point in 3d xyz projective coordinates. 3d takes less inversions than 2d. */ +class Point { + constructor(px, py, pz) { + this.px = px; + this.py = py; + this.pz = pz; + Object.freeze(this); + } + /** Create 3d xyz point from 2d xy. (0, 0) => (0, 1, 0), not (0, 0, 1) */ + static fromAffine(p) { + return ((p.x === 0n) && (p.y === 0n)) ? I : new Point(p.x, p.y, 1n); + } + /** Convert Uint8Array or hex string to Point. */ + static fromHex(hex) { + hex = toU8(hex); // convert hex string to Uint8Array + let p = undefined; + const head = hex[0], tail = hex.subarray(1); // first byte is prefix, rest is data + const x = slc(tail, 0, fLen), len = hex.length; // next 32 bytes are x coordinate + if (len === 33 && [0x02, 0x03].includes(head)) { // compressed points: 33b, start + if (!fe(x)) + err('Point hex invalid: x not FE'); // with byte 0x02 or 0x03. Check if 0 0n; d = d.double(), n >>= 1n) { // double-and-add ladder + if (n & 1n) + p = p.add(d); // if bit is present, add to point + else if (safe) + f = f.add(d); // if not, add to fake for timing safety + } + return p; + } + mulAddQUns(R, u1, u2) { + return this.mul(u1, false).add(R.mul(u2, false)).ok(); // Unsafe: do NOT use for stuff related + } // to private keys. Doesn't use Shamir trick + /** Convert point to 2d xy affine point. (x, y, z) ∋ (x=x/z, y=y/z) */ + toAffine() { + const { px: x, py: y, pz: z } = this; + if (this.equals(I)) + return { x: 0n, y: 0n }; // fast-path for zero point + if (z === 1n) + return { x, y }; // if z is 1, pass affine coordinates as-is + const iz = inv(z, P); // z^-1: invert z + if (M(z * iz) !== 1n) + err('inverse invalid'); // (z * z^-1) must be 1, otherwise bad math + return { x: M(x * iz), y: M(y * iz) }; // x = x*z^-1; y = y*z^-1 + } + /** Checks if the point is valid and on-curve. */ + assertValidity() { + const { x, y } = this.aff(); // convert to 2d xy affine point. + if (!fe(x) || !fe(y)) + err('Point invalid: x or y'); // x and y must be in range 0 < n < P + return M(y * y) === curve(x) ? // y² = x³ + ax + b, must be equal + this : err('Point invalid: not on curve'); + } + multiply(n) { return this.mul(n); } // Aliases to compress code + aff() { return this.toAffine(); } + ok() { return this.assertValidity(); } + toHex(isCompressed = true) { + const { x, y } = this.aff(); // convert to 2d xy affine point + const head = isCompressed ? ((y & 1n) === 0n ? '02' : '03') : '04'; // 0x02, 0x03, 0x04 prefix + return head + n2h(x) + (isCompressed ? '' : n2h(y)); // prefix||x and ||y + } + toRawBytes(isCompressed = true) { + return h2b(this.toHex(isCompressed)); // re-use toHex(), convert hex to bytes + } +} +/** Generator / base point */ +Point.BASE = new Point(Gx, Gy, 1n); +/** Identity / zero point */ +Point.ZERO = new Point(0n, 1n, 0n); +const { BASE: G, ZERO: I } = Point; // Generator, identity points +const padh = (n, pad) => n.toString(16).padStart(pad, '0'); +const b2h = (b) => Array.from(au8(b)).map(e => padh(e, 2)).join(''); // bytes to hex +const C = { _0: 48, _9: 57, A: 65, F: 70, a: 97, f: 102 }; // ASCII characters +const _ch = (ch) => { + if (ch >= C._0 && ch <= C._9) + return ch - C._0; // '2' => 50-48 + if (ch >= C.A && ch <= C.F) + return ch - (C.A - 10); // 'B' => 66-(65-10) + if (ch >= C.a && ch <= C.f) + return ch - (C.a - 10); // 'b' => 98-(97-10) + return; +}; +const h2b = (hex) => { + const e = 'hex invalid'; + if (!isS(hex)) + return err(e); + const hl = hex.length, al = hl / 2; + if (hl % 2) + return err(e); + const array = u8n(al); + for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) { // treat each char as ASCII + const n1 = _ch(hex.charCodeAt(hi)); // parse first char, multiply it by 16 + const n2 = _ch(hex.charCodeAt(hi + 1)); // parse second char + if (n1 === undefined || n2 === undefined) + return err(e); + array[ai] = n1 * 16 + n2; // example: 'A9' => 10*16 + 9 + } + return array; +}; +const b2n = (b) => BigInt('0x' + (b2h(b) || '0')); // bytes to number +const slc = (b, from, to) => b2n(b.slice(from, to)); // slice bytes num +const n2b = (num) => { + return isB(num) && num >= 0n && num < B256 ? h2b(padh(num, 2 * fLen)) : err('bigint expected'); +}; +const n2h = (num) => b2h(n2b(num)); // number to 32b hex +const concatB = (...arrs) => { + const r = u8n(arrs.reduce((sum, a) => sum + au8(a).length, 0)); // create u8a of summed length + let pad = 0; // walk through each array, + arrs.forEach(a => { r.set(a, pad); pad += a.length; }); // ensure they have proper type + return r; +}; +const inv = (num, md) => { + if (num === 0n || md <= 0n) + err('no inverse n=' + num + ' mod=' + md); // no neg exponent for now + let a = M(num, md), b = md, x = 0n, y = 1n, u = 1n, v = 0n; + while (a !== 0n) { // uses euclidean gcd algorithm + const q = b / a, r = b % a; // not constant-time + const m = x - u * q, n = y - v * q; + b = a, a = r, x = u, y = v, u = m, v = n; + } + return b === 1n ? M(x, md) : err('no inverse'); // b is gcd at this point +}; +const sqrt = (n) => { + let r = 1n; // So, a special, fast case. Paper: "Square Roots from 1;24,51,10 to Dan Shanks". + for (let num = n, e = (P + 1n) / 4n; e > 0n; e >>= 1n) { // powMod: modular exponentiation. + if (e & 1n) + r = (r * num) % P; // Uses exponentiation by squaring. + num = (num * num) % P; // Not constant-time. + } + return M(r * r) === n ? r : err('sqrt invalid'); // check if result is valid +}; +const toPriv = (p) => { + if (!isB(p)) + p = b2n(toU8(p, fLen)); // convert to bigint when bytes + return ge(p) ? p : err('private key invalid 3'); // check if bigint is in range +}; +const high = (n) => n > (N >> 1n); // if a number is bigger than CURVE.n/2 +/** Creates 33/65-byte public key from 32-byte private key. */ +const getPublicKey = (privKey, isCompressed = true) => { + return Point.fromPrivateKey(privKey).toRawBytes(isCompressed); +}; +/** ECDSA Signature class. Supports only compact 64-byte representation, not DER. */ +class Signature { + constructor(r, s, recovery) { + this.r = r; + this.s = s; + this.recovery = recovery; + this.assertValidity(); // recovery bit is optional when + } // constructed outside. + /** Create signature from 64b compact (r || s) representation. */ + static fromCompact(hex) { + hex = toU8(hex, 64); // compact repr is (32b r)||(32b s) + return new Signature(slc(hex, 0, fLen), slc(hex, fLen, 2 * fLen)); + } + assertValidity() { return ge(this.r) && ge(this.s) ? this : err(); } // 0 < r or s < CURVE.n + /** Create new signature, with added recovery bit. */ + addRecoveryBit(rec) { + return new Signature(this.r, this.s, rec); + } + hasHighS() { return high(this.s); } + normalizeS() { + return high(this.s) ? new Signature(this.r, M(-this.s, N), this.recovery) : this; + } + /** ECDSA public key recovery. Requires msg hash and recovery id. */ + recoverPublicKey(msgh) { + const { r, s, recovery: rec } = this; // secg.org/sec1-v2.pdf 4.1.6 + if (![0, 1, 2, 3].includes(rec)) + err('recovery id invalid'); // check recovery id + const h = bits2int_modN(toU8(msgh, fLen)); // Truncate hash + const radj = rec === 2 || rec === 3 ? r + N : r; // If rec was 2 or 3, q.x is bigger than n + if (radj >= P) + err('q.x invalid'); // ensure q.x is still a field element + const head = (rec & 1) === 0 ? '02' : '03'; // head is 0x02 or 0x03 + const R = Point.fromHex(head + n2h(radj)); // concat head + hex repr of r + const ir = inv(radj, N); // r^-1 + const u1 = M(-h * ir, N); // -hr^-1 + const u2 = M(s * ir, N); // sr^-1 + return G.mulAddQUns(R, u1, u2); // (sr^-1)R-(hr^-1)G = -(hr^-1)G + (sr^-1) + } + /** Uint8Array 64b compact (r || s) representation. */ + toCompactRawBytes() { return h2b(this.toCompactHex()); } + /** Hex string 64b compact (r || s) representation. */ + toCompactHex() { return n2h(this.r) + n2h(this.s); } +} +const bits2int = (bytes) => { + const delta = bytes.length * 8 - 256; // RFC suggests optional truncating via bits2octets + if (delta > 1024) + err('msg invalid'); // our CUSTOM check, "just-in-case" + const num = b2n(bytes); // FIPS 186-4 4.6 suggests the leftmost min(nBitLen, outLen) bits, which + return delta > 0 ? num >> BigInt(delta) : num; // matches bits2int. bits2int can produce res>N. +}; +const bits2int_modN = (bytes) => { + return M(bits2int(bytes), N); // with 0: BAD for trunc as per RFC vectors +}; +const i2o = (num) => n2b(num); // int to octets +const cr = () => // We support: 1) browsers 2) node.js 19+ 3) deno, other envs with crypto + typeof globalThis === 'object' && 'crypto' in globalThis ? globalThis.crypto : undefined; +let _hmacSync; // Can be redefined by use in utils; built-ins don't provide it +const optS = { lowS: true }; // opts for sign() +const optV = { lowS: true }; // standard opts for verify() +const prepSig = (msgh, priv, opts = optS) => { + if (['der', 'recovered', 'canonical'].some(k => k in opts)) + err('option not supported'); // legacy opts + let { lowS } = opts; // generates low-s sigs by default + if (lowS == null) + lowS = true; // RFC6979 3.2: we skip step A + const h1i = bits2int_modN(toU8(msgh)); // msg bigint + const h1o = i2o(h1i); // msg octets + const d = toPriv(priv); // validate private key, convert to bigint + const seed = [i2o(d), h1o]; // Step D of RFC6979 3.2 + let ent = opts.extraEntropy; // RFC6979 3.6: additional k' (optional) + if (ent) // K = HMAC_K(V || 0x00 || int2octets(x) || bits2octets(h1) || k') + seed.push(ent === true ? etc.randomBytes(fLen) : toU8(ent)); // true == fetch from CSPRNG + const m = h1i; // convert msg to bigint + const k2sig = (kBytes) => { + const k = bits2int(kBytes); // RFC6979 method. + if (!ge(k)) + return; // Check 0 < k < CURVE.n + const ik = inv(k, N); // k^-1 mod n, NOT mod P + const q = G.mul(k).aff(); // q = Gk + const r = M(q.x, N); // r = q.x mod n + if (r === 0n) + return; // r=0 invalid + const s = M(ik * M(m + M(d * r, N), N), N); // s = k^-1(m + rd) mod n + if (s === 0n) + return; // s=0 invalid + let normS = s; // normalized S + let rec = (q.x === r ? 0 : 2) | Number(q.y & 1n); // recovery bit + if (lowS && high(s)) { // if lowS was passed, ensure s is always + normS = M(-s, N); // in the bottom half of CURVE.n + rec ^= 1; + } + return new Signature(r, normS, rec); // use normS, not s + }; + return { seed: concatB(...seed), k2sig }; +}; +function hmacDrbg(asynchronous) { + let v = u8n(fLen); // Minimal non-full-spec HMAC-DRBG from NIST 800-90 for RFC6979 sigs. + let k = u8n(fLen); // Steps B, C of RFC6979 3.2: set hashLen, in our case always same + let i = 0; // Iterations counter, will throw when over 1000 + const reset = () => { v.fill(1); k.fill(0); i = 0; }; + const _e = 'drbg: tried 1000 values'; + if (asynchronous) { // asynchronous=true + const h = (...b) => etc.hmacSha256Async(k, v, ...b); // hmac(k)(v, ...values) + const reseed = async (seed = u8n()) => { + k = await h(u8n([0x00]), seed); // k = hmac(K || V || 0x00 || seed) + v = await h(); // v = hmac(K || V) + if (seed.length === 0) + return; + k = await h(u8n([0x01]), seed); // k = hmac(K || V || 0x01 || seed) + v = await h(); // v = hmac(K || V) + }; + const gen = async () => { + if (i++ >= 1000) + err(_e); + v = await h(); // v = hmac(K || V) + return v; + }; + return async (seed, pred) => { + reset(); // the returned fn, don't, it's: 1. slower (JIT). 2. unsafe (async race conditions) + await reseed(seed); // Steps D-G + let res = undefined; // Step H: grind until k is in [1..n-1] + while (!(res = pred(await gen()))) + await reseed(); // test predicate until it returns ok + reset(); + return res; + }; + } + else { + const h = (...b) => { + const f = _hmacSync; + if (!f) + err('etc.hmacSha256Sync not set'); + return f(k, v, ...b); // hmac(k)(v, ...values) + }; + const reseed = (seed = u8n()) => { + k = h(u8n([0x00]), seed); // k = hmac(k || v || 0x00 || seed) + v = h(); // v = hmac(k || v) + if (seed.length === 0) + return; + k = h(u8n([0x01]), seed); // k = hmac(k || v || 0x01 || seed) + v = h(); // v = hmac(k || v) + }; + const gen = () => { + if (i++ >= 1000) + err(_e); + v = h(); // v = hmac(k || v) + return v; + }; + return (seed, pred) => { + reset(); + reseed(seed); // Steps D-G + let res = undefined; // Step H: grind until k is in [1..n-1] + while (!(res = pred(gen()))) + reseed(); // test predicate until it returns ok + reset(); + return res; + }; + } +} +; +/** ECDSA signature generation. via secg.org/sec1-v2.pdf 4.1.2 + RFC6979 deterministic k. */ +/** + * Sign a msg hash using secp256k1. Async. + * It is advised to use `extraEntropy: true` (from RFC6979 3.6) to prevent fault attacks. + * Worst case: if randomness source for extraEntropy is bad, it would be as secure as if + * the option has not been used. + * @param msgh - message HASH, not message itself e.g. sha256(message) + * @param priv - private key + * @param opts - `lowS: true` to prevent malleability (s >= CURVE.n/2), `extraEntropy: boolean | Hex` to improve sig security. + */ +const signAsync = async (msgh, priv, opts = optS) => { + const { seed, k2sig } = prepSig(msgh, priv, opts); // Extract arguments for hmac-drbg + return hmacDrbg(true)(seed, k2sig); // Re-run drbg until k2sig returns ok +}; +/** + * Sign a msg hash using secp256k1. + * It is advised to use `extraEntropy: true` (from RFC6979 3.6) to prevent fault attacks. + * Worst case: if randomness source for extraEntropy is bad, it would be as secure as if + * the option has not been used. + * @param msgh - message HASH, not message itself e.g. sha256(message) + * @param priv - private key + * @param opts - `lowS: true` to prevent malleability (s >= CURVE.n/2), `extraEntropy: boolean | Hex` to improve sig security. + * @example + * const sig = sign(sha256('hello'), privKey, { extraEntropy: true }).toCompactRawBytes(); + */ +const sign = (msgh, priv, opts = optS) => { + const { seed, k2sig } = prepSig(msgh, priv, opts); // Extract arguments for hmac-drbg + return hmacDrbg(false)(seed, k2sig); // Re-run drbg until k2sig returns ok +}; +/** + * Verify a signature using secp256k1. + * @param sig - signature, 64-byte or Signature instance + * @param msgh - message HASH, not message itself e.g. sha256(message) + * @param pub - public key + * @param opts - { lowS: true } is default, prohibits s >= CURVE.n/2 to prevent malleability + */ +const verify = (sig, msgh, pub, opts = optV) => { + let { lowS } = opts; // ECDSA signature verification + if (lowS == null) + lowS = true; // Default lowS=true + if ('strict' in opts) + err('option not supported'); // legacy param + let sig_, h, P; // secg.org/sec1-v2.pdf 4.1.4 + const rs = sig && typeof sig === 'object' && 'r' in sig; // Previous ver supported DER sigs. We + if (!rs && (toU8(sig).length !== 2 * fLen)) // throw error when DER is suspected now. + err('signature must be 64 bytes'); + try { + sig_ = rs ? new Signature(sig.r, sig.s).assertValidity() : Signature.fromCompact(sig); + h = bits2int_modN(toU8(msgh)); // Truncate hash + P = pub instanceof Point ? pub.ok() : Point.fromHex(pub); // Validate public key + } + catch (e) { + return false; + } // Check sig for validity in both cases + if (!sig_) + return false; + const { r, s } = sig_; + if (lowS && high(s)) + return false; // lowS bans sig.s >= CURVE.n/2 + let R; + try { + const is = inv(s, N); // s^-1 + const u1 = M(h * is, N); // u1 = hs^-1 mod n + const u2 = M(r * is, N); // u2 = rs^-1 mod n + R = G.mulAddQUns(P, u1, u2).aff(); // R = u1⋅G + u2⋅P + } + catch (error) { + return false; + } + if (!R) + return false; // stop if R is identity / zero point + const v = M(R.x, N); // R.x must be in N's field, not P's + return v === r; // mod(R.x, n) == r +}; +/** + * Elliptic Curve Diffie-Hellman (ECDH) on secp256k1. + * Result is **NOT hashed**. Use hash on it if you need. + * @param privA private key A + * @param pubB public key B + * @param isCompressed 33-byte or 65-byte output + * @returns public key C + */ +const getSharedSecret = (privA, pubB, isCompressed = true) => { + return Point.fromHex(pubB).mul(toPriv(privA)).toRawBytes(isCompressed); // ECDH +}; +const hashToPrivateKey = (hash) => { + hash = toU8(hash); // produces private keys with modulo bias + if (hash.length < fLen + 8 || hash.length > 1024) + err('expected 40-1024b'); // being neglible. + const num = M(b2n(hash), N - 1n); // takes n+8 bytes + return n2b(num + 1n); // returns (hash mod n-1)+1 +}; +/** Math, hex, byte helpers. Not in `utils` because utils share API with noble-curves. */ +const etc = { + hexToBytes: h2b, + bytesToHex: b2h, + concatBytes: concatB, + bytesToNumberBE: b2n, + numberToBytesBE: n2b, + mod: M, + invert: inv, // math utilities + hmacSha256Async: async (key, ...msgs) => { + const c = cr(); // async HMAC-SHA256, no sync built-in! + const s = c && c.subtle; // For React Native support, see README. + if (!s) + return err('etc.hmacSha256Async or crypto.subtle must be defined'); // Uses webcrypto built-in cryptography. + const k = await s.importKey('raw', key, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign']); + return u8n(await s.sign('HMAC', k, concatB(...msgs))); + }, + hmacSha256Sync: _hmacSync, // For TypeScript. Actual logic is below + hashToPrivateKey: hashToPrivateKey, + randomBytes: (len = 32) => { + const crypto = cr(); // Must be shimmed in node.js <= 18 to prevent error. See README. + if (!crypto || !crypto.getRandomValues) + err('crypto.getRandomValues must be defined'); + return crypto.getRandomValues(u8n(len)); + }, +}; +/** Curve-specific utilities for private keys. */ +const utils = { + normPrivateKeyToScalar: toPriv, + isValidPrivateKey: (key) => { try { + return !!toPriv(key); + } + catch (e) { + return false; + } }, + randomPrivateKey: () => hashToPrivateKey(etc.randomBytes(fLen + 16)), // FIPS 186 B.4.1. + precompute: (w = 8, p = G) => { p.multiply(3n); w; return p; }, // no-op +}; +Object.defineProperties(etc, { hmacSha256Sync: { + configurable: false, get() { return _hmacSync; }, set(f) { if (!_hmacSync) + _hmacSync = f; }, + } }); +const W = 8; // Precomputes-related code. W = window size +const precompute = () => { + const points = []; // 10x sign(), 2x verify(). To achieve this, + const windows = 256 / W + 1; // app needs to spend 40ms+ to calculate + let p = G, b = p; // a lot of points related to base point G. + for (let w = 0; w < windows; w++) { // Points are stored in array and used + b = p; // any time Gx multiplication is done. + points.push(b); // They consume 16-32 MiB of RAM. + for (let i = 1; i < 2 ** (W - 1); i++) { + b = b.add(p); + points.push(b); + } + p = b.double(); // Precomputes don't speed-up getSharedKey, + } // which multiplies user point by scalar, + return points; // when precomputes are using base point +}; +let Gpows = undefined; // precomputes for base point G +const wNAF = (n) => { + // Compared to other point mult methods, + const comp = Gpows || (Gpows = precompute()); // stores 2x less points using subtraction + const neg = (cnd, p) => { let n = p.negate(); return cnd ? n : p; }; // negate + let p = I, f = G; // f must be G, or could become I in the end + const windows = 1 + 256 / W; // W=8 17 windows + const wsize = 2 ** (W - 1); // W=8 128 window size + const mask = BigInt(2 ** W - 1); // W=8 will create mask 0b11111111 + const maxNum = 2 ** W; // W=8 256 + const shiftBy = BigInt(W); // W=8 8 + for (let w = 0; w < windows; w++) { + const off = w * wsize; + let wbits = Number(n & mask); // extract W bits. + n >>= shiftBy; // shift number by W bits. + if (wbits > wsize) { + wbits -= maxNum; + n += 1n; + } // split if bits > max: +224 => 256-32 + const off1 = off, off2 = off + Math.abs(wbits) - 1; // offsets, evaluate both + const cnd1 = w % 2 !== 0, cnd2 = wbits < 0; // conditions, evaluate both + if (wbits === 0) { + f = f.add(neg(cnd1, comp[off1])); // bits are 0: add garbage to fake point + } + else { // ^ can't add off2, off2 = I + p = p.add(neg(cnd2, comp[off2])); // bits are 1: add to result point + } + } + return { p, f }; // return both real and fake points for JIT +}; // !! you can disable precomputes by commenting-out call of the wNAF() inside Point#mul() +export { getPublicKey, sign, signAsync, verify, CURVE, // Remove the export to easily use in REPL +getSharedSecret, etc, utils, Point as ProjectivePoint, Signature }; // envs like browser console diff --git a/packages/ows-signer/src/debug.js b/packages/ows-signer/src/debug.js new file mode 100644 index 0000000..01429e9 --- /dev/null +++ b/packages/ows-signer/src/debug.js @@ -0,0 +1,103 @@ +const DEBUG_KEY = "ows-signer:debug"; + +/** + * True when a chrome-extension:// (or similar) page is an ancestor. + * Chromium exposes this via `location.ancestorOrigins` even when top is + * cross-origin — useful for side-panel embeds where WebAuthn often misbehaves. + * @returns {boolean} + */ +export function isExtensionAncestor() { + try { + const origins = globalThis.location?.ancestorOrigins; + if (!origins || origins.length === 0) return false; + for (let i = 0; i < origins.length; i++) { + const origin = origins[i]; + if ( + typeof origin === "string" && + (origin.startsWith("chrome-extension://") || + origin.startsWith("moz-extension://") || + origin.startsWith("safari-web-extension://")) + ) { + return true; + } + } + } catch { + // ignore + } + return false; +} + +/** @returns {boolean} */ +export function isDebug() { + if (typeof globalThis === "undefined") return false; + if (globalThis.OWS_SIGNER_DEBUG === true) return true; + // Always log in extension side-panel embeds — GPM get() often hangs with no UI. + if (isExtensionAncestor()) return true; + try { + return ( + typeof localStorage !== "undefined" && + localStorage.getItem(DEBUG_KEY) === "1" + ); + } catch { + return false; + } +} + +/** @returns {Record} */ +export function describeCeremonyEnvironment() { + let visibilityState = null; + let hasFocus = null; + try { + visibilityState = document.visibilityState; + hasFocus = document.hasFocus?.() ?? null; + } catch { + // ignore + } + let ancestorOrigins = null; + try { + ancestorOrigins = globalThis.location?.ancestorOrigins + ? Array.from(globalThis.location.ancestorOrigins) + : null; + } catch { + ancestorOrigins = null; + } + return { + extensionAncestor: isExtensionAncestor(), + ancestorOrigins, + visibilityState, + hasFocus, + userActivationActive: navigator.userActivation?.isActive ?? null, + userActivationHasBeenActive: navigator.userActivation?.hasBeenActive ?? null, + }; +} + +/** @param {...unknown} args */ +export function debugLog(...args) { + if (isDebug()) { + console.info("[ows-signer]", ...args); + } +} + +/** + * @param {PublicKeyCredential} credential + * @returns {Record} + */ +export function describePrfExtensionResults(credential) { + const prf = credential.getClientExtensionResults()?.prf; + const raw = prf?.results?.first; + return { + prfEnabled: prf?.enabled, + hasResults: Boolean(prf?.results), + resultKeys: prf?.results ? Object.keys(prf.results) : [], + prfKeys: prf ? Object.keys(prf) : [], + rawFirstType: typeof raw, + rawFirstIsArrayBuffer: raw instanceof ArrayBuffer, + rawFirstIsView: ArrayBuffer.isView(raw), + rawFirstByteLength: + raw instanceof ArrayBuffer + ? raw.byteLength + : ArrayBuffer.isView(raw) + ? raw.byteLength + : null, + }; +} diff --git a/packages/ows-signer/src/handlers.js b/packages/ows-signer/src/handlers.js new file mode 100644 index 0000000..ad19ecf --- /dev/null +++ b/packages/ows-signer/src/handlers.js @@ -0,0 +1,1108 @@ +import { + API_VERSION, + METHODS, + SIGN_SCHEMES, + SIGNER_VERSION, +} from "./constants.js"; +import { + decryptAes256Batch, + encryptAes256Batch, +} from "./crypto/aes256.js"; +import { + deriveEd25519SeedFromSecp256k1Scalar, + deriveKeysFromCredential, + normalizePrfOutputToArrayBuffer, +} from "./crypto/prf.js"; +import { getPublicKeyAsync as edGetPublicKeyAsync } from "./crypto/vendor/noble-ed25519.js"; +import { getPublicKey as secpGetPublicKey, utils as secpUtils } from "./crypto/vendor/noble-secp256k1.js"; +import { debugLog, describePrfExtensionResults } from "./debug.js"; +import { decryptPrivateKey, encryptPrivateKey } from "./crypto/recovery.js"; +import { signWithScheme, validateSignPayload } from "./crypto/sign.js"; +import { parse0xHex, to0xHex } from "./hex.js"; +import { emitEvent } from "./rpc.js"; +import { + clearRecoveryPrivateKey, + getRecoveryPrivateKey, + hasRecoverySession, + setRecoveryPrivateKey, + withCeremony, + zeroize, +} from "./state.js"; +import { + clearUi, + CeremonyDeniedError, + promptCeremonyConfirm, + promptPassphrase, + promptPrivateKey, + showPrivateKey, + updateCeremonyCopy, +} from "./ui.js"; +import { + createPasskeyCredential, + getAssertionFieldsBase64Url, + getCredentialId, + getPasskeyAssertion, + getCosePublicKeyBase64Url, +} from "./webauthn.js"; + +/** + * @param {string} targetOrigin + * @param {string | undefined} correlationId + * @param {Uint8Array} secp256k1PublicKey + * @param {Uint8Array} ed25519PublicKey + */ +function emitKeyDerived( + targetOrigin, + correlationId, + secp256k1PublicKey, + ed25519PublicKey, +) { + emitEvent(window.parent, targetOrigin, "KeyDerived", correlationId, { + secp256k1PublicKey: to0xHex(secp256k1PublicKey), + ed25519PublicKey: to0xHex(ed25519PublicKey), + }); +} + +/** + * @param {string} method + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +export async function handleRequest( + method, + params, + correlationId, + targetOrigin, +) { + if (!METHODS.includes(method)) { + emitEvent(window.parent, targetOrigin, "InvalidRequest", correlationId, { + reason: "unknownMethod", + }); + return; + } + + try { + switch (method) { + case "getVersion": + emitEvent(window.parent, targetOrigin, "Version", correlationId, { + apiVersion: API_VERSION, + signerVersion: SIGNER_VERSION, + recoverySessionActive: hasRecoverySession(), + }); + return; + + case "createCredential": + await handleCreateCredential(params, correlationId, targetOrigin); + return; + + case "signDigest": + await handleSignDigest(params, correlationId, targetOrigin); + return; + + case "revealPrivateKey": + await handleRevealPrivateKey(params, correlationId, targetOrigin); + return; + + case "importPrivateKey": + await handleImportPrivateKey(params, correlationId, targetOrigin); + return; + + case "createRecoveryData": + await handleCreateRecoveryData(params, correlationId, targetOrigin); + return; + + case "recoverKey": + await handleRecoverKey(params, correlationId, targetOrigin); + return; + + case "getPublicKey": + await handleGetPublicKey(params, correlationId, targetOrigin); + return; + + case "clearRecoverySession": + clearRecoveryPrivateKey(); + clearUi(); + emitEvent( + window.parent, + targetOrigin, + "RecoverySessionCleared", + correlationId, + {}, + ); + return; + + case "encryptAES256": + await handleEncryptAES256(params, correlationId, targetOrigin); + return; + + case "decryptAES256": + await handleDecryptAES256(params, correlationId, targetOrigin); + return; + + case "executeBatch": + await handleExecuteBatch(params, correlationId, targetOrigin); + return; + } + } catch (error) { + handleError(error, correlationId, targetOrigin); + } +} + +/** + * @param {Record} params + * @returns {{ + * explanationHeader?: unknown, + * explanationText?: unknown, + * confirmButtonText?: unknown, + * denyButtonText?: unknown, + * }} + */ +function ceremonyFieldsFromParams(params) { + return { + explanationHeader: params.explanationHeader, + explanationText: params.explanationText, + confirmButtonText: params.confirmButtonText, + denyButtonText: params.denyButtonText, + }; +} + +/** + * PRF bytes are returned on assertion (`prf.eval`), not registration (`prf.enable`). + * Some platforms may return results on create; otherwise run a follow-up get. + * + * @param {PublicKeyCredential} credential + * @param {string} [credentialId] + * @param {(() => void) | undefined} [onBeforeFollowUpGet] + * @returns {Promise} + */ +async function credentialForKeyDerivation( + credential, + credentialId, + onBeforeFollowUpGet, +) { + debugLog("ceremony extension results", describePrfExtensionResults(credential)); + + const rawPrf = credential.getClientExtensionResults()?.prf?.results?.first; + if (normalizePrfOutputToArrayBuffer(rawPrf)) { + debugLog("using PRF output from current ceremony"); + return credential; + } + + const id = credentialId ?? getCredentialId(credential); + debugLog("no PRF results on registration; running assertion with prf.eval", { + credentialId: id, + }); + if (typeof onBeforeFollowUpGet === "function") { + onBeforeFollowUpGet(); + } + // Prefer keeping the parent Confirm panel open (call from runOnConfirm) so + // the user still sees why this second WebAuthn prompt appears. + const assertion = await getPasskeyAssertion(undefined, id); + debugLog( + "assertion extension results", + describePrfExtensionResults(assertion), + ); + return assertion; +} + +/** + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleCreateCredential(params, correlationId, targetOrigin) { + const name = params.name; + if (typeof name !== "string" || !name) { + emitInvalid(correlationId, targetOrigin, "invalidName"); + return; + } + const options = + params.options && typeof params.options === "object" + ? /** @type {{ rpName?: string, userDisplayName?: string, userId?: string, deferKeyDerivation?: boolean, explanationHeader?: string, explanationText?: string, confirmButtonText?: string, denyButtonText?: string }} */ ( + params.options + ) + : {}; + const deferKeyDerivation = options.deferKeyDerivation === true; + + await withCeremony(async () => { + /** @type {PublicKeyCredential | undefined} */ + let registrationCredential; + + if (deferKeyDerivation) { + // Registration only — opener (or another signer session) will unlock later. + await promptCeremonyConfirm(options, async () => { + registrationCredential = await createPasskeyCredential(name, options); + }); + if (!registrationCredential) { + throw new Error("createCredential: registration credential missing"); + } + emitEvent(window.parent, targetOrigin, "CredentialCreated", correlationId, { + credentialId: getCredentialId(registrationCredential), + cosePublicKey: getCosePublicKeyBase64Url(registrationCredential), + }); + return; + } + + // Create + optional PRF follow-up get stay under one Confirm panel so the + // explanation remains visible for every WebAuthn prompt in this RPC. + const prfCredential = await promptCeremonyConfirm(options, async () => { + registrationCredential = await createPasskeyCredential(name, options); + const credentialId = getCredentialId(registrationCredential); + return credentialForKeyDerivation( + registrationCredential, + credentialId, + () => { + updateCeremonyCopy({ + explanationHeader: "Unlock your new passkey", + explanationText: + "Confirm with your passkey again to finish setting up this wallet.", + confirmButtonText: options.confirmButtonText, + denyButtonText: options.denyButtonText, + }); + }, + ); + }); + if (!registrationCredential) { + throw new Error("createCredential: registration credential missing"); + } + const credentialId = getCredentialId(registrationCredential); + const keys = await deriveKeysFromCredential(prfCredential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + + emitEvent(window.parent, targetOrigin, "CredentialCreated", correlationId, { + credentialId, + cosePublicKey: getCosePublicKeyBase64Url(registrationCredential), + secp256k1PublicKey: to0xHex(keys.secp256k1PublicKey), + }); + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + }); +} + +/** + * @typedef {{ + * digestData: string, + * scheme: import('./constants.js').SignScheme, + * digest: Uint8Array, + * }} ParsedDigestItem + */ + +/** + * @param {unknown} digests + * @returns {{ ok: true, items: ParsedDigestItem[] } | { ok: false, reason: string }} + */ +function parseDigestItems(digests) { + if (!Array.isArray(digests)) { + return { ok: false, reason: "invalidParams" }; + } + /** @type {ParsedDigestItem[]} */ + const items = []; + for (const entry of digests) { + if (!entry || typeof entry !== "object") { + return { ok: false, reason: "invalidParams" }; + } + const record = /** @type {Record} */ (entry); + const digestData = record.digestData; + const scheme = record.scheme; + if (typeof digestData !== "string" || typeof scheme !== "string") { + return { ok: false, reason: "invalidParams" }; + } + if ( + !SIGN_SCHEMES.includes( + /** @type {import('./constants.js').SignScheme} */ (scheme), + ) + ) { + return { ok: false, reason: "unknownScheme" }; + } + const digest = parse0xHex(digestData); + try { + validateSignPayload( + /** @type {import('./constants.js').SignScheme} */ (scheme), + digest, + ); + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : "invalidPayload", + }; + } + items.push({ + digestData, + scheme: /** @type {import('./constants.js').SignScheme} */ (scheme), + digest, + }); + } + return { ok: true, items }; +} + +/** + * @param {ParsedDigestItem[]} items + * @param {Uint8Array} secp256k1PrivateKey + * @param {Uint8Array} ed25519Seed + * @param {string | null} credentialId + * @returns {Promise>} + */ +async function signParsedDigests( + items, + secp256k1PrivateKey, + ed25519Seed, + credentialId, +) { + /** @type {Array<{ digest: string, scheme: string, signature: string, credentialId: string | null }>} */ + const results = []; + for (const item of items) { + const signature = await signWithScheme( + item.scheme, + item.digest, + secp256k1PrivateKey, + ed25519Seed, + ); + results.push({ + digest: item.digestData, + scheme: item.scheme, + signature, + credentialId, + }); + } + return results; +} + +/** + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleSignDigest(params, correlationId, targetOrigin) { + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + const parsed = parseDigestItems(params.digests); + if (!parsed.ok) { + emitInvalid(correlationId, targetOrigin, parsed.reason); + return; + } + const { items } = parsed; + + if (items.length === 0) { + emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, { + results: [], + }); + return; + } + + if (hasRecoverySession()) { + const cached = getRecoveryPrivateKey(); + if (!cached) throw new Error("recoverySessionEmpty"); + const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(cached); + try { + const results = await signParsedDigests( + items, + cached, + ed25519Seed, + credentialId ?? null, + ); + emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, { + results, + }); + } finally { + zeroize(ed25519Seed); + } + return; + } + + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(undefined, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + try { + const results = await signParsedDigests( + items, + keys.secp256k1PrivateKey, + keys.ed25519Seed, + credentialId ?? getCredentialId(credential), + ); + emitEvent(window.parent, targetOrigin, "DigestSigned", correlationId, { + results, + }); + } finally { + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + } + }); +} + +/** + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleRevealPrivateKey(params, correlationId, targetOrigin) { + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(undefined, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + try { + const dismiss = showPrivateKey(keys.secp256k1PrivateKey); + zeroize(keys.secp256k1PrivateKey); + await dismiss; + } finally { + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + } + }); + emitEvent( + window.parent, + targetOrigin, + "PrivateKeyRevealed", + correlationId, + {}, + ); +} + +/** + * Paste a secp256k1 private key hex into the Signing Layer and start a recovery + * session (same session semantics as `recoverKey` without a backup envelope). + * + * @param {Record} _params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleImportPrivateKey(_params, correlationId, targetOrigin) { + const raw = await promptPrivateKey(); + let privateKey; + try { + privateKey = parseImportedPrivateKeyHex(raw); + } catch { + emitInvalid(correlationId, targetOrigin, "invalidPrivateKey"); + return; + } + if (!secpUtils.isValidPrivateKey(privateKey)) { + zeroize(privateKey); + emitInvalid(correlationId, targetOrigin, "invalidPrivateKey"); + return; + } + + setRecoveryPrivateKey(privateKey); + + const secp256k1PublicKey = secpGetPublicKey(privateKey, false); + const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(privateKey); + const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed); + emitKeyDerived( + targetOrigin, + correlationId, + secp256k1PublicKey, + ed25519PublicKey, + ); + zeroize(ed25519Seed); + + emitEvent( + window.parent, + targetOrigin, + "RecoverySessionStarted", + correlationId, + { recoverySessionActive: true }, + ); +} + +/** + * @param {string} raw + * @returns {Uint8Array} + */ +function parseImportedPrivateKeyHex(raw) { + const trimmed = raw.trim(); + const withPrefix = + trimmed.startsWith("0x") || trimmed.startsWith("0X") + ? trimmed + : `0x${trimmed}`; + return parse0xHex(withPrefix, 32); +} + +/** + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleCreateRecoveryData(params, correlationId, targetOrigin) { + const passwordText = params.passwordText; + const buttonText = params.buttonText; + const minPasswordLength = params.minPasswordLength; + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + + if ( + typeof passwordText !== "string" || + typeof buttonText !== "string" || + typeof minPasswordLength !== "number" + ) { + emitInvalid(correlationId, targetOrigin, "invalidParams"); + return; + } + + const passphrase = await promptPassphrase( + passwordText, + buttonText, + minPasswordLength, + ); + + // Recovery session already holds the secp256k1 scalar — no WebAuthn needed. + if (hasRecoverySession()) { + const cached = getRecoveryPrivateKey(); + if (!cached) throw new Error("recoverySessionEmpty"); + const secp256k1PublicKey = secpGetPublicKey(cached, false); + const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(cached); + const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed); + emitKeyDerived( + targetOrigin, + correlationId, + secp256k1PublicKey, + ed25519PublicKey, + ); + const encryptedPrivateKey = await encryptPrivateKey(cached, passphrase); + zeroize(ed25519Seed); + clearUi(); + emitEvent( + window.parent, + targetOrigin, + "RecoveryDataCreated", + correlationId, + { encryptedPrivateKey }, + ); + return; + } + + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(undefined, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + const encryptedPrivateKey = await encryptPrivateKey( + keys.secp256k1PrivateKey, + passphrase, + ); + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + emitEvent( + window.parent, + targetOrigin, + "RecoveryDataCreated", + correlationId, + { encryptedPrivateKey }, + ); + }); +} + +/** + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleRecoverKey(params, correlationId, targetOrigin) { + const envelope = params.aes256EncryptedPrivateKey; + const passwordText = params.passwordText; + const buttonText = params.buttonText; + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + + if ( + typeof envelope !== "string" || + typeof passwordText !== "string" || + typeof buttonText !== "string" + ) { + emitInvalid(correlationId, targetOrigin, "invalidParams"); + return; + } + + const passphrase = await promptPassphrase(passwordText, buttonText, 1); + const privateKey = await decryptPrivateKey(envelope, passphrase); + setRecoveryPrivateKey(privateKey); + + // Emit public keys so OWSSigner can cache addresses (uncompressed secp256k1 — + // viem publicKeyToAddress requires 0x04 ‖ X ‖ Y). + const secp256k1PublicKey = secpGetPublicKey(privateKey, false); + const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(privateKey); + const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed); + emitKeyDerived( + targetOrigin, + correlationId, + secp256k1PublicKey, + ed25519PublicKey, + ); + zeroize(ed25519Seed); + + showPrivateKey(privateKey); + + if (credentialId) { + await withCeremony(async () => { + await promptCeremonyConfirm(ceremonyFieldsFromParams(params), () => + getPasskeyAssertion(undefined, credentialId), + ); + clearRecoveryPrivateKey(); + }); + emitEvent( + window.parent, + targetOrigin, + "RecoverySessionCleared", + correlationId, + { rebound: true }, + ); + return; + } + + emitEvent( + window.parent, + targetOrigin, + "RecoverySessionStarted", + correlationId, + { recoverySessionActive: true }, + ); +} + +/** + * @param {unknown} value + * @returns {value is string[]} + */ +function isStringArray(value) { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +/** + * Batch AES-256-GCM seal using the secp256k1 scalar (same as `signDigest`). + * + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleEncryptAES256(params, correlationId, targetOrigin) { + const plaintexts = params.plaintexts; + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + + if (!isStringArray(plaintexts)) { + emitInvalid(correlationId, targetOrigin, "invalidParams"); + return; + } + + if (plaintexts.length === 0) { + emitEvent(window.parent, targetOrigin, "AES256Encrypted", correlationId, { + ciphertexts: [], + }); + return; + } + + if (hasRecoverySession()) { + const cached = getRecoveryPrivateKey(); + if (!cached) throw new Error("recoverySessionEmpty"); + const ciphertexts = await encryptAes256Batch(plaintexts, cached); + emitEvent(window.parent, targetOrigin, "AES256Encrypted", correlationId, { + ciphertexts, + }); + return; + } + + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(undefined, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + try { + const ciphertexts = await encryptAes256Batch( + plaintexts, + keys.secp256k1PrivateKey, + ); + emitEvent(window.parent, targetOrigin, "AES256Encrypted", correlationId, { + ciphertexts, + }); + } finally { + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + } + }); +} + +/** + * Batch AES-256-GCM unseal using the secp256k1 scalar (same as `signDigest`). + * + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleDecryptAES256(params, correlationId, targetOrigin) { + const ciphertexts = params.ciphertexts; + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + + if (!isStringArray(ciphertexts)) { + emitInvalid(correlationId, targetOrigin, "invalidParams"); + return; + } + + if (ciphertexts.length === 0) { + emitEvent(window.parent, targetOrigin, "AES256Decrypted", correlationId, { + plaintexts: [], + }); + return; + } + + if (hasRecoverySession()) { + const cached = getRecoveryPrivateKey(); + if (!cached) throw new Error("recoverySessionEmpty"); + const plaintexts = await decryptAes256Batch(ciphertexts, cached); + emitEvent(window.parent, targetOrigin, "AES256Decrypted", correlationId, { + plaintexts, + }); + return; + } + + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(undefined, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + try { + const plaintexts = await decryptAes256Batch( + ciphertexts, + keys.secp256k1PrivateKey, + ); + emitEvent(window.parent, targetOrigin, "AES256Decrypted", correlationId, { + plaintexts, + }); + } finally { + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + } + }); +} + +/** + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleGetPublicKey(params, correlationId, targetOrigin) { + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + const challenge = + typeof params.challenge === "string" + ? parse0xHex(params.challenge) + : undefined; + + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(challenge, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + + emitEvent(window.parent, targetOrigin, "PublicKey", correlationId, { + credentialId: getCredentialId(credential), + cosePublicKey: getCosePublicKeyBase64Url(credential), + secp256k1PublicKey: to0xHex(keys.secp256k1PublicKey), + ed25519PublicKey: to0xHex(keys.ed25519PublicKey), + }); + + if (challenge) { + const assertion = getAssertionFieldsBase64Url(credential); + if (assertion) { + emitEvent( + window.parent, + targetOrigin, + "ChallengeSigned", + correlationId, + { + challenge: to0xHex(challenge), + assertion, + }, + ); + } + } + + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + }); +} + +/** + * Mixed ceremony: digests + AES + public key + optional WebAuthn challenge. + * + * @param {Record} params + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +async function handleExecuteBatch(params, correlationId, targetOrigin) { + const credentialId = + typeof params.credentialId === "string" ? params.credentialId : undefined; + const includePublicKey = params.includePublicKey === true; + const challengeHex = + typeof params.challenge === "string" ? params.challenge : undefined; + const challenge = challengeHex ? parse0xHex(challengeHex) : undefined; + + /** @type {ParsedDigestItem[] | undefined} */ + let digestItems; + if (params.digests !== undefined) { + const parsed = parseDigestItems(params.digests); + if (!parsed.ok) { + emitInvalid(correlationId, targetOrigin, parsed.reason); + return; + } + digestItems = parsed.items; + } + + /** @type {string[] | undefined} */ + let plaintexts; + if (params.plaintexts !== undefined) { + if (!isStringArray(params.plaintexts)) { + emitInvalid(correlationId, targetOrigin, "invalidParams"); + return; + } + plaintexts = params.plaintexts; + } + + /** @type {string[] | undefined} */ + let ciphertextsIn; + if (params.ciphertexts !== undefined) { + if (!isStringArray(params.ciphertexts)) { + emitInvalid(correlationId, targetOrigin, "invalidParams"); + return; + } + ciphertextsIn = params.ciphertexts; + } + + const hasDigests = (digestItems?.length ?? 0) > 0; + const hasPlaintexts = (plaintexts?.length ?? 0) > 0; + const hasCiphertexts = (ciphertextsIn?.length ?? 0) > 0; + if ( + !hasDigests && + !hasPlaintexts && + !hasCiphertexts && + !includePublicKey && + !challenge + ) { + emitInvalid(correlationId, targetOrigin, "emptyBatch"); + return; + } + + /** + * @param {Uint8Array} secp256k1PrivateKey + * @param {Uint8Array} ed25519Seed + * @param {Uint8Array} secp256k1PublicKey + * @param {Uint8Array} ed25519PublicKey + * @param {string | null} resolvedCredentialId + * @param {PublicKeyCredential | null} credential + */ + async function runOps( + secp256k1PrivateKey, + ed25519Seed, + secp256k1PublicKey, + ed25519PublicKey, + resolvedCredentialId, + credential, + ) { + /** @type {Record} */ + const data = { credentialId: resolvedCredentialId }; + + if (hasDigests && digestItems) { + data.results = await signParsedDigests( + digestItems, + secp256k1PrivateKey, + ed25519Seed, + resolvedCredentialId, + ); + } + if (hasPlaintexts && plaintexts) { + data.ciphertexts = await encryptAes256Batch( + plaintexts, + secp256k1PrivateKey, + ); + } + if (hasCiphertexts && ciphertextsIn) { + data.plaintexts = await decryptAes256Batch( + ciphertextsIn, + secp256k1PrivateKey, + ); + } + if (includePublicKey) { + data.publicKey = { + credentialId: resolvedCredentialId ?? undefined, + cosePublicKey: credential + ? getCosePublicKeyBase64Url(credential) + : null, + secp256k1PublicKey: to0xHex(secp256k1PublicKey), + ed25519PublicKey: to0xHex(ed25519PublicKey), + }; + } + if (challenge && credential) { + const assertion = getAssertionFieldsBase64Url(credential); + if (assertion) { + data.assertion = assertion; + } + } + + emitEvent(window.parent, targetOrigin, "BatchExecuted", correlationId, data); + } + + // Challenge always forces a real assertion (assertion signature required). + if (challenge || !hasRecoverySession()) { + await withCeremony(async () => { + const credential = await promptCeremonyConfirm( + ceremonyFieldsFromParams(params), + () => getPasskeyAssertion(challenge, credentialId), + ); + const keys = await deriveKeysFromCredential(credential); + emitKeyDerived( + targetOrigin, + correlationId, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + ); + try { + await runOps( + keys.secp256k1PrivateKey, + keys.ed25519Seed, + keys.secp256k1PublicKey, + keys.ed25519PublicKey, + credentialId ?? getCredentialId(credential), + credential, + ); + } finally { + zeroize(keys.secp256k1PrivateKey); + zeroize(keys.ed25519Seed); + } + }); + return; + } + + const cached = getRecoveryPrivateKey(); + if (!cached) throw new Error("recoverySessionEmpty"); + const ed25519Seed = await deriveEd25519SeedFromSecp256k1Scalar(cached); + const secp256k1PublicKey = secpGetPublicKey(cached, false); + const ed25519PublicKey = await edGetPublicKeyAsync(ed25519Seed); + emitKeyDerived( + targetOrigin, + correlationId, + secp256k1PublicKey, + ed25519PublicKey, + ); + try { + await runOps( + cached, + ed25519Seed, + secp256k1PublicKey, + ed25519PublicKey, + credentialId ?? null, + null, + ); + } finally { + zeroize(ed25519Seed); + } +} + +/** + * @param {unknown} error + * @param {string | undefined} correlationId + * @param {string} targetOrigin + */ +function handleError(error, correlationId, targetOrigin) { + const name = error instanceof Error ? error.name : ""; + const message = error instanceof Error ? error.message : String(error); + + if (error instanceof CeremonyDeniedError || name === "CeremonyDeniedError") { + emitEvent(window.parent, targetOrigin, "SignDenied", correlationId, { + reason: message || "signDenied", + }); + return; + } + + if (name === "AbortError" || message === "ceremonyCancelled") { + emitEvent(window.parent, targetOrigin, "SignDenied", correlationId, { + reason: "ceremonyCancelled", + }); + return; + } + + if (name === "NotAllowedError" || message.includes("NotAllowed")) { + emitEvent(window.parent, targetOrigin, "NotAllowed", correlationId, { + reason: message || "notAllowed", + }); + return; + } + + if (message === "ceremonyInProgress") { + emitEvent(window.parent, targetOrigin, "InvalidRequest", correlationId, { + reason: "A request is already pending.", + }); + return; + } + + emitEvent(window.parent, targetOrigin, "InvalidRequest", correlationId, { + reason: message || "error", + }); +} + +/** + * @param {string | undefined} correlationId + * @param {string} targetOrigin + * @param {string} reason + */ +function emitInvalid(correlationId, targetOrigin, reason) { + emitEvent(window.parent, targetOrigin, "InvalidRequest", correlationId, { + reason, + }); +} diff --git a/packages/ows-signer/src/hex.js b/packages/ows-signer/src/hex.js new file mode 100644 index 0000000..96d9e08 --- /dev/null +++ b/packages/ows-signer/src/hex.js @@ -0,0 +1,68 @@ +/** + * @param {string} hex - with or without `0x` prefix + * @returns {Uint8Array} + */ +export function hexToBytes(hex) { + if (hex.startsWith("0x") || hex.startsWith("0X")) { + hex = hex.slice(2); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** + * @param {unknown} value + * @param {number} [expectedBytes] + * @returns {Uint8Array} + */ +export function parse0xHex(value, expectedBytes) { + if (typeof value !== "string" || !value.startsWith("0x")) { + throw new Error("Expected 0x-prefixed hex string"); + } + const hex = value.slice(2); + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { + throw new Error("Invalid hex string"); + } + const bytes = hexToBytes(hex); + if (expectedBytes !== undefined && bytes.length !== expectedBytes) { + throw new Error(`Expected ${expectedBytes} bytes, got ${bytes.length}`); + } + return bytes; +} + +/** + * @param {Uint8Array} bytes + * @returns {`0x${string}`} + */ +export function to0xHex(bytes) { + return `0x${Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`; +} + +/** + * @param {ArrayBuffer | Uint8Array} buffer + * @returns {string} + */ +export function bufferToBase64Url(buffer) { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * @param {string} value + * @returns {Uint8Array} + */ +export function base64UrlToBytes(value) { + const pad = "=".repeat((4 - (value.length % 4)) % 4); + const b64 = value.replace(/-/g, "+").replace(/_/g, "/") + pad; + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} diff --git a/packages/ows-signer/src/main.js b/packages/ows-signer/src/main.js new file mode 100644 index 0000000..3111dd5 --- /dev/null +++ b/packages/ows-signer/src/main.js @@ -0,0 +1,75 @@ +import { handleRequest } from "./handlers.js"; +import { + abandonCeremony, + getTrustedParentOrigin, + isValidNesting, + setCancelConfirmHook, + setTrustedParentOrigin, +} from "./state.js"; +import { cancelPendingCeremonyConfirm, initUi } from "./ui.js"; +import { isValidParentMessage, parseRequest } from "./rpc.js"; + +/** + * @param {MessageEvent} event + * @returns {boolean} + */ +function acceptParentMessage(event) { + if (!isValidParentMessage(event)) return false; + const trusted = getTrustedParentOrigin(); + if (trusted !== null && event.origin !== trusted) return false; + return true; +} + +function bootstrap() { + const root = document.getElementById("ows-signer-root"); + if (!root) { + throw new Error("OWS signer root element missing"); + } + initUi(root); + setCancelConfirmHook(cancelPendingCeremonyConfirm); + + if (!isValidNesting()) { + console.error("[ows-signer] Must be embedded in a wallet iframe (double iframe)."); + return; + } + + window.addEventListener("message", (event) => { + if (!acceptParentMessage(event)) return; + + const data = event.data; + if ( + data && + typeof data === "object" && + /** @type {{ kind?: unknown }} */ (data).kind === "cancel" + ) { + setTrustedParentOrigin(event.origin); + // Do not await — parent timeout must unlock immediately even if a prior + // handleRequest is still suspended on credentials.get. + void abandonCeremony(cancelPendingCeremonyConfirm); + return; + } + + if (!parseRequest(data)) return; + + setTrustedParentOrigin(event.origin); + const targetOrigin = event.origin; + const { method, correlationId, params = {} } = /** @type {{ method: string, correlationId?: string, params?: Record }} */ ( + data + ); + + void (async () => { + // Drop a stuck Confirm wait (e.g. parent timed out) before starting anew. + await abandonCeremony(cancelPendingCeremonyConfirm); + await handleRequest( + method, + /** @type {Record} */ (params), + correlationId, + targetOrigin, + ); + })().catch((error) => { + console.error("[ows-signer] request handler failed", error); + }); + }); +} + +bootstrap(); diff --git a/packages/ows-signer/src/rpc.js b/packages/ows-signer/src/rpc.js new file mode 100644 index 0000000..b188cac --- /dev/null +++ b/packages/ows-signer/src/rpc.js @@ -0,0 +1,52 @@ +/** + * @typedef {(event: string, correlationId: string | undefined, data: Record) => void} EmitFn + */ + +/** + * @param {Window} target + * @param {string} targetOrigin + * @param {string} event + * @param {string | undefined} correlationId + * @param {Record} data + */ +export function emitEvent(target, targetOrigin, event, correlationId, data) { + target.postMessage( + { + v: 1, + kind: "event", + event, + correlationId, + data, + }, + targetOrigin, + ); +} + +/** + * Branding↔Signing must be same origin. Reject cross-origin parents + * (e.g. sketchy.ru embedding wallet.1shotapi.com/signer/). + * + * @param {MessageEvent} event + * @returns {boolean} + */ +export function isValidParentMessage(event) { + if (event.source !== window.parent) return false; + if (window.parent === window.top) return false; + if (event.source === window) return false; + if (event.origin !== window.location.origin) return false; + return true; +} + +/** + * @param {unknown} data + * @returns {data is { v: number, kind: 'request', method: string, correlationId?: string, params?: Record }} + */ +export function parseRequest(data) { + if (!data || typeof data !== "object") return false; + const msg = /** @type {Record} */ (data); + return ( + msg.v === 1 && + msg.kind === "request" && + typeof msg.method === "string" + ); +} diff --git a/packages/ows-signer/src/state.js b/packages/ows-signer/src/state.js new file mode 100644 index 0000000..daedb8d --- /dev/null +++ b/packages/ows-signer/src/state.js @@ -0,0 +1,171 @@ +/** @type {Uint8Array | null} */ +let recoveryPrivateKey = null; + +/** @type {Promise | null} */ +let ceremonyPromise = null; + +/** @type {AbortController | null} */ +let ceremonyAbortController = null; + +/** @type {string | null} */ +let trustedParentOrigin = null; + +/** + * @returns {boolean} + */ +export function isValidNesting() { + try { + return window.parent !== window && window.parent !== window.top; + } catch { + return false; + } +} + +/** + * @returns {string} + */ +export function getRpId() { + return window.location.hostname; +} + +/** + * @param {string} origin + */ +export function setTrustedParentOrigin(origin) { + if (trustedParentOrigin === null) { + trustedParentOrigin = origin; + } +} + +/** + * @returns {string | null} + */ +export function getTrustedParentOrigin() { + return trustedParentOrigin; +} + +/** + * @returns {boolean} + */ +export function hasRecoverySession() { + return recoveryPrivateKey !== null; +} + +/** + * @returns {Uint8Array | null} + */ +export function getRecoveryPrivateKey() { + return recoveryPrivateKey; +} + +/** + * @param {Uint8Array} key + */ +export function setRecoveryPrivateKey(key) { + clearRecoveryPrivateKey(); + recoveryPrivateKey = key; +} + +export function clearRecoveryPrivateKey() { + if (recoveryPrivateKey) { + recoveryPrivateKey.fill(0); + recoveryPrivateKey = null; + } +} + +/** + * AbortSignal for the active ceremony (WebAuthn `credentials.get/create`). + * @returns {AbortSignal | undefined} + */ +export function getCeremonyAbortSignal() { + return ceremonyAbortController?.signal; +} + +/** @type {(() => void) | null} */ +let cancelConfirmHook = null; + +/** + * Branding/UI registers the Confirm-cancel hook so steal / abandon can dismiss + * the panel without a circular import on `ui.js`. + * + * @param {(() => void) | null} hook + */ +export function setCancelConfirmHook(hook) { + cancelConfirmHook = hook; +} + +/** + * Force-drop the ceremony lock and abort in-flight WebAuthn. + * Safe to call when no ceremony is active. + */ +function forceUnlockCeremony() { + try { + cancelConfirmHook?.(); + } catch { + // ignore + } + const abort = ceremonyAbortController; + const pending = ceremonyPromise; + ceremonyPromise = null; + ceremonyAbortController = null; + try { + abort?.abort(); + } catch { + // ignore + } + if (pending) { + void pending.catch(() => { + // Prior ceremony was cancelled, aborted, or failed. + }); + } +} + +/** + * @param {() => Promise} fn + * @returns {Promise} + * @template T + */ +export async function withCeremony(fn) { + // Steal any leftover lock (e.g. hung credentials.get after parent timeout). + // Throwing ceremonyInProgress left Branding retries stuck after RPC timeout. + if (ceremonyPromise) { + forceUnlockCeremony(); + } + ceremonyAbortController = new AbortController(); + const promise = fn(); + ceremonyPromise = promise; + try { + return await promise; + } finally { + if (ceremonyPromise === promise) { + ceremonyPromise = null; + ceremonyAbortController = null; + } + } +} + +/** + * Cancel Confirm UI, abort in-flight WebAuthn, and unlock the ceremony immediately. + * Used when the parent times out or starts a new RPC. Must not wait forever on + * hung `credentials.get` — that left Branding retries stuck with ceremonyInProgress. + * + * @param {() => void} [_cancelPendingConfirm] + */ +export async function abandonCeremony(_cancelPendingConfirm) { + // Prefer the registered hook (always current); keep the argument for callers. + forceUnlockCeremony(); + if (typeof _cancelPendingConfirm === "function") { + try { + _cancelPendingConfirm(); + } catch { + // ignore — hook already ran + } + } +} + +/** + * @param {Uint8Array} bytes + */ +export function zeroize(bytes) { + if (bytes) bytes.fill(0); +} diff --git a/packages/ows-signer/src/ui.js b/packages/ows-signer/src/ui.js new file mode 100644 index 0000000..ca2f465 --- /dev/null +++ b/packages/ows-signer/src/ui.js @@ -0,0 +1,511 @@ +import { to0xHex } from "./hex.js"; +import { debugLog, describeCeremonyEnvironment } from "./debug.js"; +import { getTrustedParentOrigin } from "./state.js"; + +/** + * Clickjack / obscured-frame gate. True when the signer document is visible + * and focused. Reveal snapshots this at the Confirm or passphrase click + * ({@link canRevealPrivateKey}); clipboard writes check it again on Copy. + * @returns {boolean} + */ +export function isSignerDocumentInteractive() { + if (typeof document === "undefined") return false; + if (document.visibilityState !== "visible") return false; + if (typeof document.hasFocus === "function" && !document.hasFocus()) { + return false; + } + return true; +} + +/** + * Key hex may be shown when the document is visible and either focused now + * or this ceremony's Confirm / passphrase click happened while it was. + * The passkey sheet blurs the iframe before reveal, so focus at reveal time + * alone rejects a ceremony the user just confirmed. + * Clipboard writes do not use this helper. + * + * @param {{ + * visibilityState: string, + * hasFocus: boolean, + * confirmedWhileInteractive: boolean, + * }} state + * @returns {boolean} + */ +export function canRevealPrivateKey(state) { + if (state.visibilityState !== "visible") return false; + return Boolean(state.hasFocus || state.confirmedWhileInteractive); +} + +/** + * Set on a Confirm or passphrase click that ran while the signer document + * was visible and focused. Consumed by {@link showPrivateKey}. + * @type {boolean} + */ +let revealConfirmedWhileInteractive = false; + +function noteRevealInteraction() { + revealConfirmedWhileInteractive = isSignerDocumentInteractive(); +} + +function clearRevealInteraction() { + revealConfirmedWhileInteractive = false; +} + +function takeRevealInteraction() { + const confirmed = revealConfirmedWhileInteractive; + revealConfirmedWhileInteractive = false; + return confirmed; +} + +/** @type {HTMLElement | null} */ +let root = null; + +/** @type {((reason?: Error) => void) | null} */ +let pendingConfirmReject = null; + +const DEFAULT_CEREMONY_HEADER = "Confirm passkey"; +const DEFAULT_CEREMONY_TEXT = + "Your device will ask for a passkey to continue."; +const DEFAULT_CONFIRM_TEXT = "Continue"; +const DEFAULT_DENY_TEXT = "Cancel"; + +const MAX_HEADER_LENGTH = 120; +const MAX_EXPLANATION_LENGTH = 2_000; +const MAX_BUTTON_LABEL_LENGTH = 64; +const MAX_PASSPHRASE_PROMPT_LENGTH = 200; + +/** + * Plain-text sanitize Branding-supplied copy before it touches the DOM. + * Tags are stripped; remaining text is assigned via `textContent` (never HTML). + * + * @param {unknown} value + * @param {{ maxLength?: number, allowNewlines?: boolean }} [options] + * @returns {string} + */ +export function sanitizeDisplayText(value, options = {}) { + if (typeof value !== "string") return ""; + const maxLength = options.maxLength ?? 500; + const allowNewlines = options.allowNewlines === true; + + let text = value.replace(/<[^>]*>/g, ""); + // Decode a few common entities so stripped markup cannot leave `<script>`-shaped bait. + text = text + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/&/gi, "&"); + // Strip again after entity decode (e.g. `<img onerror=...>`). + text = text.replace(/<[^>]*>/g, ""); + + if (allowNewlines) { + text = text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); + text = text.replace(/\r\n?/g, "\n"); + } else { + text = text.replace(/[\u0000-\u001F\u007F]/g, " "); + text = text.replace(/\s+/g, " "); + } + + text = text.trim(); + if (text.length > maxLength) { + text = text.slice(0, maxLength).trimEnd(); + } + return text; +} + +/** + * @param {HTMLElement} container + */ +export function initUi(container) { + root = container; + root.innerHTML = ""; + root.className = "ows-signer-ui"; +} + +function setCeremonyOpen(open) { + if (typeof document !== "undefined" && document.body) { + document.body.classList.toggle("ows-ceremony-open", open); + } +} + +export function clearUi() { + setCeremonyOpen(false); + if (root) { + root.innerHTML = ""; + root.className = "ows-signer-ui"; + } +} + +/** + * Error thrown when the user cancels the ceremony Confirm UI. + * Mapped to the `SignDenied` event by handlers. + */ +export class CeremonyDeniedError extends Error { + constructor(message = "signDenied") { + super(message); + this.name = "CeremonyDeniedError"; + } +} + +/** + * Abort an open Confirm panel (e.g. client timeout or superseding RPC). + * Does nothing if no confirm is waiting. + */ +export function cancelPendingCeremonyConfirm() { + if (!pendingConfirmReject) return; + clearRevealInteraction(); + const reject = pendingConfirmReject; + pendingConfirmReject = null; + clearUi(); + reject(new CeremonyDeniedError("ceremonyCancelled")); +} + +/** + * @typedef {{ + * explanationHeader?: string, + * explanationText?: string, + * confirmButtonText?: string, + * denyButtonText?: string, + * }} CeremonyUiFields + */ + +/** + * Resolve ceremony copy with defaults. + * @param {CeremonyUiFields | Record | undefined} fields + */ +export function resolveCeremonyUi(fields) { + const src = fields && typeof fields === "object" ? fields : {}; + const header = sanitizeDisplayText(src.explanationHeader, { + maxLength: MAX_HEADER_LENGTH, + }); + const explanation = sanitizeDisplayText(src.explanationText, { + maxLength: MAX_EXPLANATION_LENGTH, + allowNewlines: true, + }); + const confirmButtonText = sanitizeDisplayText(src.confirmButtonText, { + maxLength: MAX_BUTTON_LABEL_LENGTH, + }); + const denyButtonText = sanitizeDisplayText(src.denyButtonText, { + maxLength: MAX_BUTTON_LABEL_LENGTH, + }); + return { + header: header || DEFAULT_CEREMONY_HEADER, + explanation: explanation || DEFAULT_CEREMONY_TEXT, + confirmButtonText: confirmButtonText || DEFAULT_CONFIRM_TEXT, + denyButtonText: denyButtonText || DEFAULT_DENY_TEXT, + }; +} + +/** + * Update header/body on an open ceremony Confirm panel (e.g. before a follow-up + * WebAuthn get). No-op if the ceremony UI is not showing. + * + * @param {CeremonyUiFields | Record | undefined} fields + */ +export function updateCeremonyCopy(fields) { + if (!root) return; + const header = root.querySelector(".ows-ceremony-header"); + const text = root.querySelector(".ows-ceremony-text"); + if (!header && !text) return; + const ui = resolveCeremonyUi(fields); + if (header) header.textContent = ui.header; + if (text) text.textContent = ui.explanation; +} + +/** + * Show Confirm/Cancel. On Confirm, runs `runOnConfirm` **synchronously from the + * click handler** so WebAuthn keeps user activation / document focus on mobile. + * + * Copy stays visible while the ceremony runs; buttons are disabled until it + * settles. The panel is cleared only on deny, success, failure, or cancel. + * + * Layout: header + actions stay fixed; only explanation text scrolls. + * Branding strings are sanitized then set with `textContent` (plain text only). + * + * @template T + * @param {CeremonyUiFields | Record | undefined} fields + * @param {() => T | Promise} runOnConfirm + * @returns {Promise} + */ +export function promptCeremonyConfirm(fields, runOnConfirm) { + if (!root) { + return Promise.reject(new Error("uiNotInitialized")); + } + cancelPendingCeremonyConfirm(); + clearRevealInteraction(); + clearUi(); + root.className = "ows-signer-ui ows-signer-ui--ceremony"; + setCeremonyOpen(true); + const ui = resolveCeremonyUi(fields); + + return new Promise((resolve, reject) => { + pendingConfirmReject = reject; + + const grab = document.createElement("div"); + grab.className = "ows-ceremony-grab"; + grab.setAttribute("aria-hidden", "true"); + + const header = document.createElement("h2"); + header.className = "ows-ceremony-header"; + header.textContent = ui.header; + + const body = document.createElement("div"); + body.className = "ows-ceremony-body"; + const text = document.createElement("p"); + text.className = "ows-ceremony-text"; + text.textContent = ui.explanation; + body.append(text); + + const requesterOrigin = + getTrustedParentOrigin() ?? window.location.origin; + const originLine = document.createElement("p"); + originLine.className = "ows-ceremony-origin"; + originLine.textContent = `Requester: ${requesterOrigin}`; + body.append(originLine); + + const actions = document.createElement("div"); + actions.className = "ows-ceremony-actions"; + + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "ows-confirm"; + confirmBtn.textContent = ui.confirmButtonText; + + const denyBtn = document.createElement("button"); + denyBtn.type = "button"; + denyBtn.className = "ows-deny"; + denyBtn.textContent = ui.denyButtonText; + + const finishWaiting = () => { + pendingConfirmReject = null; + }; + + const settleSuccess = (result) => { + // Cancel may have already cleared the waiter / panel. + if (pendingConfirmReject !== reject) return; + finishWaiting(); + clearUi(); + resolve(result); + }; + + const settleFailure = (error) => { + clearRevealInteraction(); + if (pendingConfirmReject !== reject) return; + finishWaiting(); + clearUi(); + reject(error); + }; + + confirmBtn.addEventListener("click", () => { + confirmBtn.disabled = true; + denyBtn.disabled = true; + // Snapshot focus before credentials.get(). The passkey sheet blurs + // this iframe, so reveal cannot rely on hasFocus() afterwards. + noteRevealInteraction(); + try { + // Start WebAuthn in the same turn as the click (no prior await). + // Keep copy visible until the ceremony settles. + debugLog("ceremony Confirm click", describeCeremonyEnvironment()); + Promise.resolve(runOnConfirm()).then(settleSuccess, settleFailure); + } catch (error) { + settleFailure(error); + } + }); + + denyBtn.addEventListener("click", () => { + clearRevealInteraction(); + finishWaiting(); + clearUi(); + reject(new CeremonyDeniedError()); + }); + + actions.append(confirmBtn, denyBtn); + root.append(grab, header, body, actions); + }); +} + +/** + * Show the private key hex with Copy + Done. Resolves when the user clicks Done + * (or the UI is cleared), so Branding can keep the ceremony panel open. + * + * The document must be visible. Focus is required unless this ceremony's + * Confirm or passphrase click already happened while the signer was + * interactive — the passkey sheet blurs the iframe before reveal. + * Clipboard writes still require a focused document at the Copy click. + * + * @param {Uint8Array} privateKey + * @returns {Promise} + */ +export function showPrivateKey(privateKey) { + if (!root) { + return Promise.reject(new Error("uiNotInitialized")); + } + try { + window.focus(); + } catch { + // Nested iframe focus can be ignored after the passkey sheet. + } + const visibilityState = + typeof document !== "undefined" ? document.visibilityState : "hidden"; + const hasFocus = + typeof document !== "undefined" && + (typeof document.hasFocus !== "function" || document.hasFocus()); + const confirmedWhileInteractive = takeRevealInteraction(); + if ( + !canRevealPrivateKey({ + visibilityState, + hasFocus, + confirmedWhileInteractive, + }) + ) { + return Promise.reject(new Error("signerNotInteractive")); + } + cancelPendingCeremonyConfirm(); + clearUi(); + const hex = to0xHex(privateKey); + return new Promise((resolve) => { + const pre = document.createElement("pre"); + pre.className = "ows-key"; + pre.textContent = hex; + const copyBtn = document.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "ows-copy"; + copyBtn.textContent = "Copy"; + copyBtn.addEventListener("click", async () => { + if (!isSignerDocumentInteractive()) return; + await navigator.clipboard.writeText(pre.textContent ?? ""); + }); + const doneBtn = document.createElement("button"); + doneBtn.type = "button"; + doneBtn.className = "ows-confirm"; + doneBtn.textContent = "Done"; + doneBtn.addEventListener("click", () => { + clearUi(); + resolve(); + }); + const actions = document.createElement("div"); + actions.className = "ows-actions"; + actions.append(copyBtn, doneBtn); + root.append(pre, actions); + }); +} + +/** + * @param {string} passwordText + * @param {string} buttonText + * @param {number} minPasswordLength + * @returns {Promise} + */ +export function promptPassphrase(passwordText, buttonText, minPasswordLength) { + if (!root) { + return Promise.reject(new Error("uiNotInitialized")); + } + cancelPendingCeremonyConfirm(); + clearRevealInteraction(); + clearUi(); + const safePlaceholder = sanitizeDisplayText(passwordText, { + maxLength: MAX_PASSPHRASE_PROMPT_LENGTH, + }); + const safeButton = sanitizeDisplayText(buttonText, { + maxLength: MAX_BUTTON_LABEL_LENGTH, + }); + return new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "password"; + input.className = "ows-password"; + input.placeholder = safePlaceholder || "Passphrase"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ows-confirm"; + button.textContent = safeButton || "Continue"; + button.addEventListener("click", () => { + if (input.value.length < minPasswordLength) { + clearRevealInteraction(); + reject(new Error("passwordTooShort")); + return; + } + // recoverKey shows the key after this click's awaits, once focus may be gone. + noteRevealInteraction(); + resolve(input.value); + clearUi(); + }); + root.append(input, button); + }); +} + +/** + * Prompt for a secp256k1 private key hex (with or without `0x`). Resolves with + * the trimmed hex string the user entered; rejects on Cancel. + * + * @returns {Promise} + */ +export function promptPrivateKey() { + if (!root) { + return Promise.reject(new Error("uiNotInitialized")); + } + cancelPendingCeremonyConfirm(); + clearUi(); + root.className = + "ows-signer-ui ows-signer-ui--ceremony ows-signer-ui--key-entry"; + setCeremonyOpen(true); + return new Promise((resolve, reject) => { + const grab = document.createElement("div"); + grab.className = "ows-ceremony-grab"; + grab.setAttribute("aria-hidden", "true"); + + const header = document.createElement("h2"); + header.className = "ows-ceremony-header"; + header.textContent = "Import private key"; + + const body = document.createElement("div"); + body.className = "ows-ceremony-body"; + const text = document.createElement("p"); + text.className = "ows-ceremony-text"; + text.textContent = + "Paste your EVM private key to recover your 1Shot wallet accounts."; + const input = document.createElement("input"); + input.type = "password"; + input.className = "ows-password"; + input.placeholder = "0x…"; + input.autocomplete = "off"; + input.spellcheck = false; + const errorEl = document.createElement("p"); + errorEl.className = "ows-ceremony-text"; + errorEl.style.color = "tomato"; + errorEl.hidden = true; + body.append(text, input, errorEl); + + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "ows-confirm"; + confirmBtn.textContent = "Import"; + const denyBtn = document.createElement("button"); + denyBtn.type = "button"; + denyBtn.className = "ows-deny"; + denyBtn.textContent = "Cancel"; + const actions = document.createElement("div"); + actions.className = "ows-ceremony-actions"; + actions.append(confirmBtn, denyBtn); + + const finish = (fn) => { + clearUi(); + fn(); + }; + + confirmBtn.addEventListener("click", () => { + const value = input.value.trim(); + if (!value) { + errorEl.textContent = "Enter a private key."; + errorEl.hidden = false; + return; + } + finish(() => resolve(value)); + }); + denyBtn.addEventListener("click", () => { + finish(() => reject(new CeremonyDeniedError())); + }); + + root.append(grab, header, body, actions); + input.focus(); + }); +} diff --git a/packages/ows-signer/src/webauthn.js b/packages/ows-signer/src/webauthn.js new file mode 100644 index 0000000..6fc02ed --- /dev/null +++ b/packages/ows-signer/src/webauthn.js @@ -0,0 +1,307 @@ +import { getCeremonyAbortSignal, getRpId } from "./state.js"; +import { PRF_LABEL_SECP256K1 } from "./constants.js"; +import { bufferToBase64Url } from "./hex.js"; +import { + debugLog, + describeCeremonyEnvironment, + describePrfExtensionResults, +} from "./debug.js"; + +/** + * @param {Uint8Array} [userId] + * @returns {Uint8Array} + */ +function randomUserId(userId) { + if (userId) return userId; + return crypto.getRandomValues(new Uint8Array(16)); +} + +/** + * @param {string} name + * @param {{ rpName?: string, userDisplayName?: string, userId?: string }} [options] + * @returns {Promise} + */ +export async function createPasskeyCredential(name, options = {}) { + const rpId = getRpId(); + const rpName = + typeof options.rpName === "string" && options.rpName + ? options.rpName + : "OWS"; + const userId = options.userId + ? Uint8Array.from(atob(options.userId), (c) => c.charCodeAt(0)) + : randomUserId(); + const displayName = options.userDisplayName ?? name; + const signal = getCeremonyAbortSignal(); + + debugLog("createPasskeyCredential start", describeCeremonyEnvironment()); + + const credential = await navigator.credentials.create({ + publicKey: { + rp: { name: rpName, id: rpId }, + user: { + id: userId, + name, + displayName, + }, + challenge: crypto.getRandomValues(new Uint8Array(32)), + // Chromium warns when ES256 (-7) is present without RS256 (-257). + // Prefer ES256 (PRF); RS256 is listed for authenticator compatibility. + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + authenticatorSelection: { + residentKey: "required", + requireResidentKey: true, + userVerification: "required", + }, + extensions: { + prf: { + enable: { + first: PRF_LABEL_SECP256K1, + }, + }, + }, + }, + ...(signal ? { signal } : {}), + }); + + if (!credential || !(credential instanceof PublicKeyCredential)) { + throw new Error("credentialCreationFailed"); + } + debugLog("createPasskeyCredential", describePrfExtensionResults(credential)); + return credential; +} + +/** + * @param {Uint8Array} [challenge] + * @param {string} [credentialId] + * @returns {Promise} + */ +export async function getPasskeyAssertion(challenge, credentialId) { + const rpId = getRpId(); + const allowCredentials = credentialId + ? [ + { + type: "public-key", + id: base64UrlToBytes(credentialId), + }, + ] + : undefined; + const signal = getCeremonyAbortSignal(); + + debugLog("getPasskeyAssertion start", { + ...describeCeremonyEnvironment(), + rpId, + hasChallenge: Boolean(challenge), + hasAllowCredentials: Boolean(allowCredentials), + credentialIdPrefix: + typeof credentialId === "string" ? credentialId.slice(0, 8) : null, + }); + + let credential; + try { + credential = await navigator.credentials.get({ + publicKey: { + challenge: challenge ?? crypto.getRandomValues(new Uint8Array(32)), + rpId, + userVerification: "required", + allowCredentials, + extensions: { + prf: { + eval: { + first: PRF_LABEL_SECP256K1, + }, + }, + }, + }, + ...(signal ? { signal } : {}), + }); + } catch (error) { + debugLog("getPasskeyAssertion rejected", { + ...describeCeremonyEnvironment(), + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + + if (!credential || !(credential instanceof PublicKeyCredential)) { + throw new Error("credentialGetFailed"); + } + debugLog("getPasskeyAssertion ok", describePrfExtensionResults(credential)); + return credential; +} + +/** + * @param {string} value + * @returns {Uint8Array} + */ +function base64UrlToBytes(value) { + const pad = "=".repeat((4 - (value.length % 4)) % 4); + const b64 = value.replace(/-/g, "+").replace(/_/g, "/") + pad; + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +/** + * @param {PublicKeyCredential} credential + * @returns {string} + */ +export function getCredentialId(credential) { + return bufferToBase64Url(credential.rawId); +} + +/** + * @param {PublicKeyCredential} credential + * @returns {string | null} COSE credentialPublicKey as base64url, or null + */ +export function getCosePublicKeyBase64Url(credential) { + const response = credential.response; + // COSE lives in attestation authenticator data only — not on assertions, + // and not via AuthenticatorAttestationResponse.getPublicKey() (that is SPKI). + if ( + !("getAuthenticatorData" in response) || + typeof response.getAuthenticatorData !== "function" + ) { + return null; + } + const authData = response.getAuthenticatorData(); + if (!authData) return null; + const cose = extractCosePublicKeyFromAuthenticatorData( + new Uint8Array(authData), + ); + return cose ? bufferToBase64Url(cose) : null; +} + +/** + * Slice the COSE credentialPublicKey from attested credential data. + * @param {Uint8Array} authData + * @returns {Uint8Array | null} + */ +function extractCosePublicKeyFromAuthenticatorData(authData) { + if (authData.length < 37) return null; + const flags = authData[32]; + const hasAttestedCredentialData = (flags & 0x40) !== 0; + if (!hasAttestedCredentialData) return null; + + let offset = 37; // rpIdHash(32) + flags(1) + signCount(4) + offset += 16; // aaguid + if (offset + 2 > authData.length) return null; + const credentialIdLength = (authData[offset] << 8) | authData[offset + 1]; + offset += 2 + credentialIdLength; + if (offset >= authData.length) return null; + + try { + const length = cborFirstItemLength(authData, offset); + return authData.subarray(offset, offset + length); + } catch { + return null; + } +} + +/** + * Byte length of the first definite-length CBOR item at `offset`. + * Sufficient for WebAuthn COSE public keys (maps of ints / bstrs). + * @param {Uint8Array} buf + * @param {number} offset + * @returns {number} + */ +function cborFirstItemLength(buf, offset) { + if (offset >= buf.length) throw new Error("cborTruncated"); + const initial = buf[offset]; + const major = initial >> 5; + const additional = initial & 31; + let pos = offset + 1; + let argument = additional; + + if (additional === 24) { + argument = buf[pos]; + pos += 1; + } else if (additional === 25) { + argument = (buf[pos] << 8) | buf[pos + 1]; + pos += 2; + } else if (additional === 26) { + argument = + (buf[pos] << 24) | + (buf[pos + 1] << 16) | + (buf[pos + 2] << 8) | + buf[pos + 3]; + pos += 4; + } else if (additional === 27) { + // 64-bit; WebAuthn keys never need this size + throw new Error("cborUint64Unsupported"); + } else if (additional >= 28) { + throw new Error("cborIndefiniteUnsupported"); + } + + if (major === 0 || major === 1) { + return pos - offset; + } + if (major === 2 || major === 3) { + return pos - offset + argument; + } + if (major === 7) { + if (additional < 24) return pos - offset; + if (additional === 25) return pos - offset + 2; + if (additional === 26) return pos - offset + 4; + if (additional === 27) return pos - offset + 8; + return pos - offset; + } + if (major === 6) { + const inner = cborFirstItemLength(buf, pos); + return pos - offset + inner; + } + if (major === 4 || major === 5) { + const valueCount = major === 5 ? argument * 2 : argument; + for (let i = 0; i < valueCount; i++) { + pos += cborFirstItemLength(buf, pos); + } + return pos - offset; + } + throw new Error("cborUnsupportedMajor"); +} + +/** + * @param {PublicKeyCredential} credential + * @returns {string | null} + */ +export function getAssertionSignatureBase64Url(credential) { + const response = credential.response; + if ("signature" in response && response.signature) { + return bufferToBase64Url(response.signature); + } + return null; +} + +/** + * Full WebAuthn assertion fields for host/relayer auth (base64url). + * @param {PublicKeyCredential} credential + * @returns {{ + * authenticatorData: string, + * clientDataJSON: string, + * signature: string, + * credentialId: string, + * } | null} + */ +export function getAssertionFieldsBase64Url(credential) { + const response = credential.response; + if ( + !("authenticatorData" in response) || + !response.authenticatorData || + !("clientDataJSON" in response) || + !response.clientDataJSON || + !("signature" in response) || + !response.signature + ) { + return null; + } + return { + authenticatorData: bufferToBase64Url(response.authenticatorData), + clientDataJSON: bufferToBase64Url(response.clientDataJSON), + signature: bufferToBase64Url(response.signature), + credentialId: getCredentialId(credential), + }; +} diff --git a/packages/ows-signer/test/signer.test.js b/packages/ows-signer/test/signer.test.js new file mode 100644 index 0000000..9cfab43 --- /dev/null +++ b/packages/ows-signer/test/signer.test.js @@ -0,0 +1,448 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parse0xHex, to0xHex, hexToBytes, bufferToBase64Url, base64UrlToBytes } from "../src/hex.js"; +import { parseRequest } from "../src/rpc.js"; +import { SIGN_SCHEMES, METHODS } from "../src/constants.js"; +import { encryptPrivateKey, decryptPrivateKey } from "../src/crypto/recovery.js"; +import { validateSignPayload } from "../src/crypto/sign.js"; + +describe("hex", () => { + it("round-trips bytes", () => { + const bytes = new Uint8Array([0, 1, 255]); + assert.equal(to0xHex(bytes), "0x0001ff"); + assert.deepEqual(parse0xHex("0x0001ff"), bytes); + assert.deepEqual(hexToBytes("0x0001ff"), bytes); + assert.deepEqual(hexToBytes("0001ff"), bytes); + }); + + it("base64url round-trips", () => { + const bytes = new Uint8Array([1, 2, 3, 250]); + const encoded = bufferToBase64Url(bytes); + assert.deepEqual(base64UrlToBytes(encoded), bytes); + }); + + it("rejects invalid hex", () => { + assert.throws(() => parse0xHex("0x0g")); + assert.throws(() => parse0xHex("abc")); + }); +}); + +describe("rpc", () => { + it("parses valid requests", () => { + assert.equal( + parseRequest({ v: 1, kind: "request", method: "getVersion" }), + true, + ); + assert.equal(parseRequest({ v: 2, kind: "request", method: "x" }), false); + }); + + it("isValidParentMessage requires same-origin parent nesting", async () => { + const { isValidParentMessage } = await import("../src/rpc.js"); + const origin = "https://wallet.example"; + const parent = {}; + const top = {}; + const previousWindow = globalThis.window; + globalThis.window = { + parent, + top, + location: { origin }, + }; + try { + assert.equal( + isValidParentMessage({ + source: parent, + origin, + }), + true, + ); + assert.equal( + isValidParentMessage({ + source: parent, + origin: "https://sketchy.example", + }), + false, + ); + globalThis.window = { parent, top: parent, location: { origin } }; + assert.equal( + isValidParentMessage({ source: parent, origin }), + false, + ); + } finally { + globalThis.window = previousWindow; + } + }); +}); + +describe("constants", () => { + it("includes expected methods and schemes", () => { + assert.ok(METHODS.includes("signDigest")); + assert.ok(METHODS.includes("encryptAES256")); + assert.ok(METHODS.includes("decryptAES256")); + assert.ok(METHODS.includes("executeBatch")); + assert.ok(SIGN_SCHEMES.includes("secp256k1-ecdsa-recoverable")); + }); +}); + +describe("recovery", () => { + it("encrypts and decrypts private key envelope", async () => { + const key = crypto.getRandomValues(new Uint8Array(32)); + key[31] = 1; + const envelope = await encryptPrivateKey(key, "test-passphrase-123"); + assert.match(envelope, /^ows1:0x/); + const decrypted = await decryptPrivateKey(envelope, "test-passphrase-123"); + assert.deepEqual(decrypted, key); + }); + + it("fails on wrong passphrase", async () => { + const key = crypto.getRandomValues(new Uint8Array(32)); + const envelope = await encryptPrivateKey(key, "correct"); + await assert.rejects(() => decryptPrivateKey(envelope, "wrong")); + }); +}); + +describe("aes256", () => { + it("round-trips batch envelopes from secp256k1 material", async () => { + const { + AES256_ENVELOPE_PREFIX, + decryptAes256Batch, + encryptAes256Batch, + } = await import("../src/crypto/aes256.js"); + const secpKey = crypto.getRandomValues(new Uint8Array(32)); + secpKey[31] = 1; + const plaintexts = ["hello", '{"credential":true}', ""]; + const ciphertexts = await encryptAes256Batch(plaintexts, secpKey); + assert.equal(ciphertexts.length, 3); + for (const c of ciphertexts) { + assert.ok(c.startsWith(AES256_ENVELOPE_PREFIX)); + } + const decrypted = await decryptAes256Batch(ciphertexts, secpKey); + assert.deepEqual(decrypted, plaintexts); + }); + + it("fails decrypt with wrong key material", async () => { + const { decryptAes256Batch, encryptAes256Batch } = await import( + "../src/crypto/aes256.js" + ); + const keyA = crypto.getRandomValues(new Uint8Array(32)); + const keyB = crypto.getRandomValues(new Uint8Array(32)); + keyA[31] = 1; + keyB[31] = 2; + const [envelope] = await encryptAes256Batch(["secret"], keyA); + await assert.rejects(() => decryptAes256Batch([envelope], keyB)); + }); + + it("rejects invalid envelopes", async () => { + const { decryptAes256Batch } = await import("../src/crypto/aes256.js"); + const key = crypto.getRandomValues(new Uint8Array(32)); + key[31] = 1; + await assert.rejects(() => + decryptAes256Batch(["ows1:0x010203"], key), + ); + }); +}); + +describe("ed25519 derivation", () => { + /** + * @param {Uint8Array} prfOutput + */ + function mockPrfCredential(prfOutput) { + return { + getClientExtensionResults() { + return { prf: { results: { first: prfOutput } } }; + }, + }; + } + + it("is deterministic for a given secp256k1 scalar", async () => { + const { deriveEd25519SeedFromSecp256k1Scalar } = await import( + "../src/crypto/prf.js" + ); + const scalar = crypto.getRandomValues(new Uint8Array(32)); + scalar[31] |= 1; + const a = await deriveEd25519SeedFromSecp256k1Scalar(scalar); + const b = await deriveEd25519SeedFromSecp256k1Scalar(scalar); + assert.deepEqual(a, b); + assert.equal(a.length, 32); + }); + + it("matches between PRF derivation and recovery/import (scalar-only) path", async () => { + const { + deriveKeysFromCredential, + deriveEd25519SeedFromSecp256k1Scalar, + } = await import("../src/crypto/prf.js"); + const { getPublicKeyAsync } = await import( + "../src/crypto/vendor/noble-ed25519.js" + ); + const prf = crypto.getRandomValues(new Uint8Array(32)); + const fromPrf = await deriveKeysFromCredential( + /** @type {PublicKeyCredential} */ (mockPrfCredential(prf)), + ); + const fromScalar = await deriveEd25519SeedFromSecp256k1Scalar( + fromPrf.secp256k1PrivateKey, + ); + assert.deepEqual(fromPrf.ed25519Seed, fromScalar); + assert.deepEqual( + fromPrf.ed25519PublicKey, + await getPublicKeyAsync(fromScalar), + ); + }); + + it("does not use raw PRF as Ed25519 IKM (same label, different key)", async () => { + const { deriveKeysFromCredential, hkdfExpand } = await import( + "../src/crypto/prf.js" + ); + const { PRF_LABEL_ED25519 } = await import("../src/constants.js"); + const prf = crypto.getRandomValues(new Uint8Array(32)); + const fromPrf = await deriveKeysFromCredential( + /** @type {PublicKeyCredential} */ (mockPrfCredential(prf)), + ); + const directFromPrf = await hkdfExpand( + prf.buffer.slice(prf.byteOffset, prf.byteOffset + prf.byteLength), + PRF_LABEL_ED25519, + 32, + ); + assert.notDeepEqual(fromPrf.ed25519Seed, directFromPrf); + }); + + it("produces a different seed for a different scalar", async () => { + const { deriveEd25519SeedFromSecp256k1Scalar } = await import( + "../src/crypto/prf.js" + ); + const a = crypto.getRandomValues(new Uint8Array(32)); + const b = crypto.getRandomValues(new Uint8Array(32)); + a[31] |= 1; + b[31] |= 2; + assert.notDeepEqual( + await deriveEd25519SeedFromSecp256k1Scalar(a), + await deriveEd25519SeedFromSecp256k1Scalar(b), + ); + }); + + it("reads only the 32-byte scalar window from a larger buffer", async () => { + const { deriveEd25519SeedFromSecp256k1Scalar } = await import( + "../src/crypto/prf.js" + ); + const scalar = crypto.getRandomValues(new Uint8Array(32)); + scalar[31] |= 1; + const padded = new Uint8Array(64); + padded.set(scalar, 8); + const view = padded.subarray(8, 40); + assert.deepEqual( + await deriveEd25519SeedFromSecp256k1Scalar(view), + await deriveEd25519SeedFromSecp256k1Scalar(scalar), + ); + }); + + it("signs ed25519 with the scalar-derived seed against that seed's pubkey", async () => { + const { deriveEd25519SeedFromSecp256k1Scalar } = await import( + "../src/crypto/prf.js" + ); + const { signWithScheme } = await import("../src/crypto/sign.js"); + const { getPublicKeyAsync, verifyAsync } = await import( + "../src/crypto/vendor/noble-ed25519.js" + ); + const scalar = crypto.getRandomValues(new Uint8Array(32)); + scalar[31] |= 1; + const seed = await deriveEd25519SeedFromSecp256k1Scalar(scalar); + const digest = crypto.getRandomValues(new Uint8Array(32)); + const signatureHex = await signWithScheme("ed25519", digest, scalar, seed); + const signature = parse0xHex(signatureHex); + const pub = await getPublicKeyAsync(seed); + assert.equal(await verifyAsync(signature, digest, pub), true); + }); +}); + +describe("sign validation", () => { + it("requires 32-byte digest for secp256k1", () => { + assert.throws(() => + validateSignPayload("secp256k1-ecdsa", new Uint8Array(31)), + ); + validateSignPayload("secp256k1-ecdsa", new Uint8Array(32)); + }); +}); + +describe("sign recoverable", () => { + it("emits 65-byte signatures with v in {27, 28}", async () => { + const { signWithScheme } = await import("../src/crypto/sign.js"); + const digest = crypto.getRandomValues(new Uint8Array(32)); + const priv = crypto.getRandomValues(new Uint8Array(32)); + priv[0] %= 0xf0; + priv[31] |= 1; + const signature = await signWithScheme( + "secp256k1-ecdsa-recoverable", + digest, + priv, + new Uint8Array(32), + ); + assert.match(signature, /^0x[0-9a-f]{130}$/i); + const v = Number.parseInt(signature.slice(-2), 16); + assert.ok(v === 27 || v === 28, `expected v 27|28, got ${v}`); + }); + + it("compact scheme stays 64 bytes", async () => { + const { signWithScheme } = await import("../src/crypto/sign.js"); + const digest = crypto.getRandomValues(new Uint8Array(32)); + const priv = crypto.getRandomValues(new Uint8Array(32)); + priv[0] %= 0xf0; + priv[31] |= 1; + const signature = await signWithScheme( + "secp256k1-ecdsa", + digest, + priv, + new Uint8Array(32), + ); + assert.match(signature, /^0x[0-9a-f]{128}$/i); + }); +}); + +describe("ceremony UI", () => { + it("resolveCeremonyUi applies defaults", async () => { + const { resolveCeremonyUi, CeremonyDeniedError } = await import( + "../src/ui.js" + ); + const defaults = resolveCeremonyUi({}); + assert.equal(defaults.header, "Confirm passkey"); + assert.equal(defaults.confirmButtonText, "Continue"); + assert.equal(defaults.denyButtonText, "Cancel"); + + const custom = resolveCeremonyUi({ + explanationHeader: "Unlock", + explanationText: "Tap continue", + confirmButtonText: "Go", + denyButtonText: "No", + }); + assert.equal(custom.header, "Unlock"); + assert.equal(custom.explanation, "Tap continue"); + assert.equal(custom.confirmButtonText, "Go"); + assert.equal(custom.denyButtonText, "No"); + + const err = new CeremonyDeniedError(); + assert.equal(err.name, "CeremonyDeniedError"); + }); + + it("sanitizeDisplayText strips HTML and entities", async () => { + const { sanitizeDisplayText, resolveCeremonyUi } = await import( + "../src/ui.js" + ); + assert.equal( + sanitizeDisplayText('Hello'), + "Hello", + ); + assert.equal( + sanitizeDisplayText("<script>alert(1)</script>Safe"), + "alert(1)Safe", + ); + assert.equal( + sanitizeDisplayText("line1\nline2", { allowNewlines: true }), + "line1\nline2", + ); + assert.equal(sanitizeDisplayText("a\nb", { allowNewlines: false }), "a b"); + + const ui = resolveCeremonyUi({ + explanationHeader: "Confirm", + explanationText: 'Click please', + confirmButtonText: "OK", + denyButtonText: "No", + }); + assert.equal(ui.header, "Confirm"); + assert.equal(ui.explanation, "Click please"); + // Tag innards become plain text (not executable) — intentional. + assert.equal(ui.confirmButtonText, "xOK"); + }); +}); + +describe("canRevealPrivateKey", () => { + it("never reveals a hidden document", async () => { + const { canRevealPrivateKey } = await import("../src/ui.js"); + assert.equal( + canRevealPrivateKey({ + visibilityState: "hidden", + hasFocus: true, + confirmedWhileInteractive: true, + }), + false, + ); + }); + + it("reveals when the document is visible and focused", async () => { + const { canRevealPrivateKey } = await import("../src/ui.js"); + assert.equal( + canRevealPrivateKey({ + visibilityState: "visible", + hasFocus: true, + confirmedWhileInteractive: false, + }), + true, + ); + }); + + it("reveals after WebAuthn when Confirm was interactive", async () => { + const { canRevealPrivateKey } = await import("../src/ui.js"); + assert.equal( + canRevealPrivateKey({ + visibilityState: "visible", + hasFocus: false, + confirmedWhileInteractive: true, + }), + true, + ); + }); + + it("does not reveal when visible, unfocused, and not confirmed", async () => { + const { canRevealPrivateKey } = await import("../src/ui.js"); + assert.equal( + canRevealPrivateKey({ + visibilityState: "visible", + hasFocus: false, + confirmedWhileInteractive: false, + }), + false, + ); + }); +}); + +describe("ceremony lock", () => { + it("abandonCeremony clears lock after cancel rejects the waiter", async () => { + const { withCeremony, abandonCeremony } = await import("../src/state.js"); + + /** @type {(error: Error) => void} */ + let rejectWait; + const held = withCeremony( + () => + new Promise((_, reject) => { + rejectWait = reject; + }), + ); + + await Promise.resolve(); + + await abandonCeremony(() => { + rejectWait(new Error("ceremonyCancelled")); + }); + await assert.rejects(() => held, /ceremonyCancelled/); + + const result = await withCeremony(async () => "ok"); + assert.equal(result, "ok"); + }); + + it("abandonCeremony unlocks even if the ceremony never settles", async () => { + const { withCeremony, abandonCeremony } = await import("../src/state.js"); + + void withCeremony(() => new Promise(() => {})); + await Promise.resolve(); + + await abandonCeremony(() => {}); + const result = await withCeremony(async () => "ok"); + assert.equal(result, "ok"); + }); + + it("withCeremony steals a stuck lock instead of throwing ceremonyInProgress", async () => { + const { withCeremony } = await import("../src/state.js"); + + void withCeremony(() => new Promise(() => {})); + await Promise.resolve(); + + const result = await withCeremony(async () => "stolen"); + assert.equal(result, "stolen"); + }); +}); diff --git a/packages/ows-types/CHANGELOG.md b/packages/ows-types/CHANGELOG.md new file mode 100644 index 0000000..38c03a0 --- /dev/null +++ b/packages/ows-types/CHANGELOG.md @@ -0,0 +1,177 @@ +# @1shotapi/ows-types + +## 0.15.0 + +### Minor Changes + +- 17fe471: **ows-provider:** Add Wallet Standard discovery on `proxy.solana.registerSolanaWallet()` / `unregisterSolanaWallet()`, forwarding connect and Solana features to the existing Branding wire. Host-side `@solana/transactions` helpers split/merge full serialized transactions for `signTransaction` / `signAndSendTransaction`. + + **ows-types:** Solana Phase 2 primitives and utils for Branding/Host: + + - Add `SolanaATAAddress` + `SolanaAddressUtils.asAtaAddress` + - Rename `SolanaTransactionSignature` → `SolanaTransactionHash` (breaking rename; use the new export) + - Add `TransactionHash` union (`EVMTransactionHash | SolanaTransactionHash`) and `parseTransactionHash` + - Add `ConversionUtils.bytesToBase64` / `base64ToBytes` (standard base64 as `Base64String`) for Host↔Branding wire payloads + +## 0.14.0 + +### Minor Changes + +- 972fd3b: Solana Phase 1: CAIP-2 `SolanaChainId` (`solana:mainnet` / `devnet` / `testnet`), branded program/mint/signature primitives, `SolanaSigner.signMessage` / `signTransaction` + Kit partial signer, and Host/Branding `solana.*` wire methods (`proxy.solana` / `wallet.solana.register`). + +## 0.13.0 + +### Minor Changes + +- 88a9f66: Remove RPC timeouts across Host and Signing layers. `RpcHostClient` (ows-provider) and `SignerRpcClient` (ows-signer-utils) no longer impose a 120s deadline — user-paced requests (`personal_sign`, `wallet_requestExecutionPermissions`, signer Confirm / WebAuthn, …) stay pending until the other layer responds, or reject on destroy / session clear. Removed: `OWSProxyOptions.rpcTimeoutMs`, `OWSSignerOptions.rpcTimeoutMs`, `RequestOptions.timeoutMs`, `DEFAULT_RPC_TIMEOUT_MS`, `OwsRpcTimeoutError`, and `OwsTimeoutError`. Hosts that need a bound on a non-interactive call should race their own deadline at the call site. + +## 0.12.0 + +### Minor Changes + +- 6cd2357: Add `ChainUtils.asEVMChainId` (throws `OwsInvalidParamsError`) to normalize EIP-155 chain ids from number/string/bigint (with trim), and use it from signer/wallet EIP-1193 helpers instead of local `normalizeChainId` wrappers. Add `IEVMTransactionRequestSchema` and parse `eth_sendTransaction` params with Zod. + +## 0.11.0 + +### Minor Changes + +- 61ea18f: Add Zod 4 schemas on branded primitives (and `EChainTechnology`), with viem checksum / `@scure/base` address checks. Split AES-256 into `AES256CipherText` (raw hex) and `AES256CipherTextEnvelope` (`ows-aes1:…`). Wallet-utils EIP-1193 and credential composites now import the shared primitive schemas. + +## 0.10.0 + +### Minor Changes + +- 02b6de2: Add `IAppendedCaveatConfiguration` and optional `caveats?: IAppendedCaveatConfiguration[]` on `IExecutionPermissionRequest`. + + Hosts can now append extra caveats to a top-level EIP-7715 scope on a single delegation, mirroring `createDelegation({ scope, caveats })` in `@metamask/smart-accounts-kit`. The field is optional and additive, so existing requests are unchanged. + +## 0.8.0 + +### Minor Changes + +- 042beab: Bitcoin chain ids are now descriptive string sentinels (`"Bitcoin"`, `"BitcoinTestnet"`) instead of numeric `-1`/`-2`. Add `ChainUtils` for narrowing `OWSChainId` across EVM, Bitcoin, and Solana, plus `SolanaChainId` sentinels. + +## 0.7.0 + +### Minor Changes + +- 728eb8d: Add Bitcoin Native SegWit (P2WPKH) support: + - `ows-types`: Replace `BitcoinAccountAddress` with `BitcoinSegwitAccountAddress`. Add `BitcoinSignatureHex`, `BitcoinTransactionHash`, `BitcoinChainId` (-1 mainnet, -2 testnet), `OWSChainId`, BIP-122 static wallet types, and dual-network Bitcoin cache methods on `IOWSSigner`. + - `ows-signer-utils`: Add `BitcoinSigner` (`signer.bitcoin`), SegWit address derivation (`addressFromSecp256k1PublicKey`), BIP-143 transaction marshalling, and dual-network caching. + - `ows-provider`: Add `BitcoinHostClient` (`proxy.bitcoin.getAccountAddresses`) conforming to Reown BIP-122 static-wallet pattern. + - `ows-wallet-utils`: Add `validateBitcoinSegwitAddress` with strict bech32 SegWit v0 validation, and `BitcoinWalletRegistrar` (`wallet.bitcoin.register`). + +## 0.6.0 + +### Minor Changes + +- 4c9757f: Add `ConversionUtils.addressToBytes32Hex` to left-pad a 20-byte EVM address (`0x${string}`) to `bytes32` without duplicating pad helpers in consumers. + +## 0.5.1 + +### Patch Changes + +- 36fa253: Wire Branding→Host EIP-1193 notifications (`chainChanged`, `accountsChanged`) over Postmate so hosts stay in sync when the wallet changes chain or accounts. + +## 0.5.0 + +### Minor Changes + +- df9c4b7: Return full WebAuthn assertion fields (`authenticatorData`, `clientDataJSON`, `signature`, `credentialId`) from challenge-capable Signing Layer ceremonies as nested `assertion`, replacing `challengeSignature`. + +## 0.4.0 + +### Minor Changes + +- 09afc87: Add Branding→Host analytics wire (`ows:analytics`): `IOWSAnalyticsEvent` / abstract `OWSAnalyticsEvent` (branding subclasses) with index-signature extras, `OWSWallet.analytics.emit`, and `OWSProxy.analytics` EventEmitter. + +## 0.3.1 + +### Patch Changes + +- Add EVMContractAddress, DomainString, and UnixTimestamp branded primitives + + - EVMContractAddress: contract deployments vs account addresses (EIP-7715 factory / delegationManager) + - DomainString: hostname without scheme or path (distinct from UriString) + - UnixTimestamp: Unix time in seconds + +## 0.3.0 + +### Minor Changes + +- ee8dcf7: Add EIP-7715 execution-permission types and optional RpcHelper hooks + + ows-types exports PermissionRequest/Response shapes and the four draft method names on the EIP-1193 method table. RpcHelper registers those methods when branding passes `executionPermissions` callbacks; UI remains app-owned. + +## 0.2.4 + +### Patch Changes + +- 2f6bb67: Host-owned wallet sizing: flyout vs full-screen drawer, no branding size requests. + + `requestDisplay` is a show session only (no width/height). On small viewports the host panel opens as a full-screen drawer with a bottom wipe; larger viewports keep the lower-right flyout at `walletSizeX`/`walletSizeY`. + +## 0.2.3 + +### Patch Changes + +- c8fd466: Add `createCredential` option `deferKeyDerivation` so Safari first-party registration can skip the PRF follow-up; `CredentialCreated.secp256k1PublicKey` is optional when deferred. + +## 0.2.2 + +### Patch Changes + +- 09d93bb: Add `importPrivateKey` — paste a hex secp256k1 key in the Signing Layer to start a recovery session. + +## 0.2.1 + +### Patch Changes + +- 08133a8: revealPrivateKey keeps the ceremony panel open until the user dismisses the key UI (PrivateKeyRevealed). + +## 0.2.0 + +### Minor Changes + +- ae79e0f: Batch digest signing and `executeBatch` mixed ceremony so one passkey covers many signs/AES/auth ops; signing APIs are array-only (breaking). +- a39b7b2: Signing Layer passkey Confirm/Cancel UI (ceremony params + `SignDenied` / `OwsSignDeniedError`) and visible ceremony panel; remove invisible 1×1 WebAuthn prep and fold `credentialId` into options bags. + +## 0.1.3 + +### Patch Changes + +- 498d823: Add EChainTechnology and RelayerTransactionId; move IBlockchainProvider and AddressUtils (ENS-aware) into ows-wallet-utils with viem as a peer dependency. + +## 0.1.2 + +### Patch Changes + +- Add typed eth_sendTransaction support: branded transaction hash, RpcHelper active-chain request/broadcast helpers, and SignHelper prepare → sign → broadcast with transaction consent. + +## 0.1.1 + +### Patch Changes + +- afc99f3: Replace PasskeyPublicKey with branded COSEPublicKey / SPKIPublicKey. Signing Layer emits COSE (`cosePublicKey`) from attestation authenticator data; add COSEToSPKIPublicKey in ows-signer-utils for Web Crypto consumers. + +## 0.1.0 + +### Minor Changes + +- 0e1b1d1: Move SD-JWT VC presentation and crypto utilities from `ows-wallet-utils` into `ows-types` (`utils/credentials/`). Drop `ows-provider`'s dependency on `ows-wallet-utils`; host apps use `@1shotapi/ows-types` for presentation helpers and `@1shotapi/ows-provider` for verify/proxy only. + + **Breaking:** `buildSdJwtVcPresentation`, `extractHolderJwkFromSdJwtVc`, and crypto helpers are no longer exported from `@1shotapi/ows-wallet-utils` or re-exported from `@1shotapi/ows-provider`. Use `PresentationUtils` / `CredentialCryptoUtils` from `@1shotapi/ows-types`. `CredentialCryptoUtils.createOwsEd25519HolderSigner` lives on `@1shotapi/ows-types`. + +- aaf43d4: Add `OWSWallet.requestDisplay()` / `requestHide()` so the branding iframe can ask the host to show or hide a lower-right wallet flyout (opaque panel, no modal backdrop) before WebAuthn or approval UI in cross-origin embeds. Host-side `OWSProxy` handles `ows:requestDisplay`, `ows:requestHide`, and `ows:releaseDisplay`. +- e83e5ce: Fold verifiable credentials into core OWS packages: types and OID4 interfaces in `ows-types`, branding wire and SD-JWT presentation in `ows-wallet-utils`, host proxy and SD-JWT verify in `ows-provider`. Demo mocks live under `examples/shared` (not published). Removes `@1shotapi/ows-credentials`. +- 3c8b506: Add OID4VCI holder proof-of-possession via `ProofUtils` (`buildOid4vciProofJwt` / `verifyOid4vciProofJwt`, branded `JWKThumbprint`) and require proof in issuance context. Enforce `kb+jwt` audience during SD-JWT VC presentation verify. +- 89e40a9: Phase 3 credentials hardening: `ICredentialRepository`, `IIssuerTrustRegistry.isTrustedIssuer`, `CredentialsHelper`, host `acceptedIssuers` on present, fail-closed status checks, and stubbed `OWSSigner.encryptAES256` / `decryptAES256` with branded `AES256CipherText`. +- a78bba8: Phase 4 credentials: optional `@1shotapi/ows-oid4` add-on (`CredentialsHelper`, HTTP OID4VCI/OID4VP clients). Holder bridge is `CredentialCryptoUtils.createOwsEd25519HolderSigner` in `ows-types`. Wire registrar/Zod schemas stay in `ows-wallet-utils`. `IOWSSigner` lives in `ows-types` (implemented by `OWSSigner`). Verifier/issuer HTTP demos own their server-side JWE helpers. +- 9a87faf: Move shared EIP-1193 method lists and typed request tables into `@1shotapi/ows-types`. Add `RpcHelper` in `ows-wallet-utils` to register JSON-RPC read methods and chain switching on `OWSWallet` (replaces the registry rpc-provider module for branding apps). +- eae834a: Add `EVMChainId` and allow branding modules to register additional EIP-1193 methods (e.g. JSON-RPC reads) on the wallet Postmate model. +- aaf43d4: Add `@1shotapi/ows-types` for shared OWS error classes and wire protocol types used across host, wallet, and signer SDKs. + +### Patch Changes + +- 55f9a22: Implement PRF-derived `encryptAES256` / `decryptAES256` in the Signing Layer: AES-256-GCM keyed via HKDF from the wallet secp256k1 scalar (same material as `signDigest`), `ows-aes1:` envelopes, batch + recovery-session paths. +- 8eeecf2: Add branded `HexString`, `Base64UrlEncodedString`, and `SdJwtVcPresentationString` primitives. Add branded public key primitives (`PasskeyPublicKey`, `SECP256K1PublicKey`, `ED25519PublicKey`) and apply them to signer protocol types and RPC parsing in `ows-signer-utils`. diff --git a/packages/ows-types/README.md b/packages/ows-types/README.md new file mode 100644 index 0000000..79613af --- /dev/null +++ b/packages/ows-types/README.md @@ -0,0 +1,21 @@ +# @1shotapi/ows-types + +Shared TypeScript types and error classes for the Open Wallet Standard (OWS) SDKs. + +## Contents + +- **Host ↔ Branding RPC** — Postmate callback envelopes, JSON-RPC error codes, serde helpers +- **Host ↔ Branding analytics** — `OWS_ANALYTICS_EVENT`, `IOWSAnalyticsEvent` / `OWSAnalyticsEvent` (required base; branding extends the class; extras passthrough on the wire) +- **Branding ↔ Signing RPC** — Signing Layer wire protocol types +- **Errors** — `OwsRpcError` / `OwsSignerError` hierarchies used by `@1shotapi/ows-provider`, `@1shotapi/ows-wallet-utils`, and `@1shotapi/ows-signer-utils` + +## Usage + +```ts +import { + OwsRpcError, + OwsUserRejectedError, + OWS_RPC_CALLBACK_EVENT, + type RpcRequestEnvelope, +} from "@1shotapi/ows-types"; +``` diff --git a/packages/ows-types/package.json b/packages/ows-types/package.json new file mode 100644 index 0000000..3d41a44 --- /dev/null +++ b/packages/ows-types/package.json @@ -0,0 +1,51 @@ +{ + "name": "@1shotapi/ows-types", + "version": "0.15.0", + "description": "Shared OWS TypeScript types and error classes for host, wallet, and signer SDKs", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "node --import tsx --test test/*.test.ts", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "tsx": "^4.19.0" + }, + "keywords": [ + "ows", + "open-wallet-standard", + "types", + "errors" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-types" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@scure/base": "^2.4.0", + "@sd-jwt/core": "^0.20.0", + "@sd-jwt/sd-jwt-vc": "^0.20.0", + "@solana/addresses": "^8.5.0", + "ts-brand": "^0.2.0", + "viem": "^2.52.0", + "zod": "^4.6.5" + } +} diff --git a/packages/ows-types/src/credentials/credential.ts b/packages/ows-types/src/credentials/credential.ts new file mode 100644 index 0000000..dc7890a --- /dev/null +++ b/packages/ows-types/src/credentials/credential.ts @@ -0,0 +1,49 @@ +import type { + CredentialId, + CredentialIssuer, + CredentialTypeName, + ISO8601DateTime, + UriString, +} from "../primitives/index.js"; +import type { CredentialFormat } from "./format.js"; + +/** W3C VC 2.0 semantic subset — transport-agnostic. */ +export type CredentialStatus = { + id?: UriString; + type: string; + statusListCredential?: UriString; + statusListIndex?: string; +}; + +export type CredentialSchemaRef = { + id: UriString; + type: string; +}; + +export type CredentialSubject = Record; + +export type VerifiableCredentialSemantic = { + id?: UriString; + type: CredentialTypeName[]; + issuer: CredentialIssuer | { id: CredentialIssuer; name?: string }; + validFrom?: ISO8601DateTime; + validUntil?: ISO8601DateTime; + credentialSubject: CredentialSubject; + credentialStatus?: CredentialStatus; + credentialSchema?: CredentialSchemaRef; + evidence?: unknown[]; + termsOfUse?: unknown[]; +}; + +/** Credential as stored in the wallet (payload + metadata). */ +export type StoredCredential = { + credentialId: CredentialId; + format: CredentialFormat; + type: CredentialTypeName[]; + issuer: CredentialIssuer; + issuedAt: ISO8601DateTime; + validUntil?: ISO8601DateTime; + /** Opaque credential payload (SD-JWT, JWT, JSON-LD doc, …). */ + payload: string; + semantic: VerifiableCredentialSemantic; +}; diff --git a/packages/ows-types/src/credentials/dcql.ts b/packages/ows-types/src/credentials/dcql.ts new file mode 100644 index 0000000..3036eff --- /dev/null +++ b/packages/ows-types/src/credentials/dcql.ts @@ -0,0 +1,28 @@ +import type { CredentialClaimName } from "../primitives/CredentialClaimName.js"; +import type { CredentialTypeName } from "../primitives/CredentialTypeName.js"; + +/** + * Supported DCQL subset for OWS Phase 4 (fail closed on unsupported shapes). + * Maps to {@link import("./presentation.js").PresentationDefinition} claim lists. + */ +export type OwsDcqlClaimQuery = { + path: string[]; +}; + +export type OwsDcqlCredentialQuery = { + id: string; + format: string; + meta?: { + vct_values?: string[]; + }; + claims?: OwsDcqlClaimQuery[]; +}; + +export type OwsDcqlQuery = { + credentials: OwsDcqlCredentialQuery[]; +}; + +export type DcqlMapResult = { + requestedClaims: CredentialClaimName[]; + credentialTypes: CredentialTypeName[]; +}; diff --git a/packages/ows-types/src/credentials/filter.ts b/packages/ows-types/src/credentials/filter.ts new file mode 100644 index 0000000..f4a343a --- /dev/null +++ b/packages/ows-types/src/credentials/filter.ts @@ -0,0 +1,21 @@ +import type { + CredentialId, + CredentialIssuer, + CredentialTypeName, + ISO8601DateTime, +} from "../primitives/index.js"; +import type { CredentialFormat } from "./format.js"; + +export type CredentialFilter = { + type?: CredentialTypeName; + issuer?: CredentialIssuer; +}; + +export type CredentialSummary = { + credentialId: CredentialId; + type: CredentialTypeName[]; + issuer: CredentialIssuer; + format: CredentialFormat; + issuedAt: ISO8601DateTime; + validUntil?: ISO8601DateTime; +}; diff --git a/packages/ows-types/src/credentials/format.ts b/packages/ows-types/src/credentials/format.ts new file mode 100644 index 0000000..5ae8454 --- /dev/null +++ b/packages/ows-types/src/credentials/format.ts @@ -0,0 +1,6 @@ +/** Pluggable credential encoding formats. */ +export type CredentialFormat = + | "sd-jwt-vc" + | "jwt-vc" + | "json-ld" + | "mdoc"; diff --git a/packages/ows-types/src/credentials/holder-signer.ts b/packages/ows-types/src/credentials/holder-signer.ts new file mode 100644 index 0000000..5916b9e --- /dev/null +++ b/packages/ows-types/src/credentials/holder-signer.ts @@ -0,0 +1,9 @@ +import type { Base64UrlEncodedString } from "../primitives/index.js"; + +/** Holder key used for SD-JWT VC key binding (`kb+jwt`). Branding wires this to the OWS signer. */ +export interface IHolderSigner { + /** Ed25519 public key as JWK (`cnf.jwk` at issuance; kb+jwt verification). */ + publicKeyJwk(): Promise; + /** Signs the kb+jwt unsigned token (UTF-8 JWT signing input). */ + signKbJwt(unsignedJwt: string): Promise; +} diff --git a/packages/ows-types/src/credentials/index.ts b/packages/ows-types/src/credentials/index.ts new file mode 100644 index 0000000..1d0748f --- /dev/null +++ b/packages/ows-types/src/credentials/index.ts @@ -0,0 +1,65 @@ +export type { CredentialFormat } from "./format.js"; +export type { + CredentialOffer, + CredentialOfferApprovalRequest, + CredentialOfferInput, + CredentialReceipt, + CredentialOfferGrants, + PreAuthorizedCodeGrant, +} from "./offer.js"; +export type { + PresentationDefinition, + PresentationRequestInput, + PresentationResult, + CredentialPresentationApprovalRequest, + Oid4vpAuthorizationRequest, + Oid4vpClientMetadata, + Oid4vpResponseMode, +} from "./presentation.js"; +export type { + OwsDcqlQuery, + OwsDcqlCredentialQuery, + OwsDcqlClaimQuery, + DcqlMapResult, +} from "./dcql.js"; +export type { IWalletAttestationProvider } from "./wallet-attestation.js"; +export type { + CredentialStatus, + CredentialSchemaRef, + CredentialSubject, + VerifiableCredentialSemantic, + StoredCredential, +} from "./credential.js"; +export type { CredentialFilter, CredentialSummary } from "./filter.js"; +export type { CredentialStatusCheck } from "./status.js"; +export type { + AssuranceLevel, + IssuerTrustMetadata, + TrustRegistryEntry, +} from "./trust.js"; +export type { KycProfilePolicy } from "./kyc-profile.js"; +export type { IHolderSigner } from "./holder-signer.js"; +export type { ICredentialRepository } from "./storage.js"; +export type { + CredentialConfigurationMetadata, + IssuerMetadata, + CredentialIssuanceContext, + CredentialRequestPreparation, + Oid4vciJwtProof, + IOid4vciClient, +} from "./oid4vci.js"; +export { + OID4VCI_PROOF_JWT_TYP, + type BuildOid4vciProofJwtInput, + type VerifyOid4vciProofJwtInput, + type VerifyOid4vciProofJwtResult, +} from "./oid4vci-proof.js"; +export type { + PresentationBuildContext, + IOid4vpClient, +} from "./oid4vp.js"; +export { + type ICredentialStatusValidator, + NoopCredentialStatusValidator, +} from "./status-validator.js"; +export type { IIssuerTrustRegistry } from "./trust-registry.js"; diff --git a/packages/ows-types/src/credentials/kyc-profile.ts b/packages/ows-types/src/credentials/kyc-profile.ts new file mode 100644 index 0000000..4865fd2 --- /dev/null +++ b/packages/ows-types/src/credentials/kyc-profile.ts @@ -0,0 +1,9 @@ +import type { CredentialClaimName, CredentialIssuer } from "../primitives/index.js"; +import type { AssuranceLevel } from "./trust.js"; + +export type KycProfilePolicy = { + requiredClaims: CredentialClaimName[]; + minAssuranceLevel: AssuranceLevel; + maxAgeDays: number; + allowedIssuers: CredentialIssuer[]; +}; diff --git a/packages/ows-types/src/credentials/offer.ts b/packages/ows-types/src/credentials/offer.ts new file mode 100644 index 0000000..729786b --- /dev/null +++ b/packages/ows-types/src/credentials/offer.ts @@ -0,0 +1,52 @@ +import type { + CredentialConfigurationId, + CredentialFormatId, + CredentialId, + CredentialIssuer, + CredentialOfferUri, + CredentialScope, + CredentialTypeName, +} from "../primitives/index.js"; +import type { CredentialFormat } from "./format.js"; + +/** Pre-authorized code grant (OID4VCI). */ +export type PreAuthorizedCodeGrant = { + "pre-authorized_code": string; + tx_code?: { + input_mode?: string; + length?: number; + description?: string; + }; + user_pin_required?: boolean; +}; + +export type CredentialOfferGrants = { + "urn:ietf:params:oauth:grant-type:pre-authorized_code"?: PreAuthorizedCodeGrant; +}; + +export type CredentialOffer = { + credentialIssuer: CredentialIssuer; + credentialConfigurationIds: CredentialConfigurationId[]; + grants?: CredentialOfferGrants; +}; + +export type CredentialOfferInput = { + credentialOfferUri?: CredentialOfferUri; + offer?: CredentialOffer; +}; + +export type CredentialReceipt = { + credentialId: CredentialId; + format: CredentialFormat; + type: CredentialTypeName[]; +}; + +export type CredentialOfferApprovalRequest = { + issuerName: string; + issuerId: CredentialIssuer; + offeredCredentials: Array<{ + configurationId: CredentialConfigurationId; + format: CredentialFormatId; + scope?: CredentialScope; + }>; +}; diff --git a/packages/ows-types/src/credentials/oid4vci-proof.ts b/packages/ows-types/src/credentials/oid4vci-proof.ts new file mode 100644 index 0000000..d72355b --- /dev/null +++ b/packages/ows-types/src/credentials/oid4vci-proof.ts @@ -0,0 +1,24 @@ +import type { IHolderSigner } from "./holder-signer.js"; + +/** OID4VCI JWT proof `typ` header value. */ +export const OID4VCI_PROOF_JWT_TYP = "openid4vci-proof+jwt"; + +export type BuildOid4vciProofJwtInput = { + holderSigner: IHolderSigner; + /** Credential issuer identifier (`aud`). */ + audience: string; + /** C-nonce from the issuer when available. */ + nonce?: string; +}; + +export type VerifyOid4vciProofJwtInput = { + jwt: string; + expectedAudience: string; + expectedNonce?: string; +}; + +export type VerifyOid4vciProofJwtResult = { + valid: boolean; + holderPublicKeyJwk?: JsonWebKey; + reasons: string[]; +}; diff --git a/packages/ows-types/src/credentials/oid4vci.ts b/packages/ows-types/src/credentials/oid4vci.ts new file mode 100644 index 0000000..f6e457e --- /dev/null +++ b/packages/ows-types/src/credentials/oid4vci.ts @@ -0,0 +1,78 @@ +import type { + CredentialConfigurationId, + CredentialFormatId, + CredentialIssuer, + CredentialOfferUri, + CredentialScope, + UriString, +} from "../primitives/index.js"; +import type { StoredCredential } from "./credential.js"; +import type { CredentialOffer } from "./offer.js"; + +export type CredentialConfigurationMetadata = { + format: CredentialFormatId; + scope?: CredentialScope; +}; + +export type IssuerMetadata = { + credentialIssuer: CredentialIssuer; + credentialConfigurationsSupported: Record< + CredentialConfigurationId, + CredentialConfigurationMetadata + >; + /** Absolute credential endpoint URL (from well-known). */ + credentialEndpoint?: UriString; + /** Absolute token endpoint URL (issuer or AS). */ + tokenEndpoint?: UriString; + /** Optional JWKS URI for issuer keys. */ + jwksUri?: UriString; + /** Proof types the issuer accepts (e.g. `jwt`). */ + proofTypesSupported?: string[]; +}; + +/** OID4VCI credential-request proof (`proof_type: jwt`). */ +export type Oid4vciJwtProof = { + proof_type: "jwt"; + jwt: string; +}; + +/** + * Wallet-supplied holder key material and proof-of-possession for issuance. + * Mock and HTTP clients verify {@link proof} before embedding `cnf.jwk`. + */ +export type CredentialIssuanceContext = { + /** Holder public key embedded as SD-JWT VC `cnf.jwk` after PoP verification. */ + holderPublicKeyJwk: JsonWebKey; + /** OID4VCI JWT proof proving possession of {@link holderPublicKeyJwk}. */ + proof: Oid4vciJwtProof; + /** Issuer C-nonce echoed in the proof when the issuer supplied one. */ + nonce?: string; + /** Wallet attestation JWT when the issuer requires one. */ + walletAttestationJwt?: string; +}; + +/** Result of pre-authorized token exchange (optional on mock clients). */ +export type CredentialRequestPreparation = { + accessToken?: string; + /** Issuer C-nonce for the holder proof JWT. */ + cNonce?: string; + cNonceExpiresIn?: number; +}; + +export interface IOid4vciClient { + resolveOffer(uri: CredentialOfferUri): Promise; + fetchIssuerMetadata(issuer: CredentialIssuer): Promise; + /** + * Optional pre-authorized token step. HTTP clients implement this to obtain + * `c_nonce` before the wallet builds the PoP JWT. Mock clients omit it. + */ + prepareCredentialRequest?( + offer: CredentialOffer, + metadata: IssuerMetadata, + ): Promise; + requestCredential( + offer: CredentialOffer, + metadata: IssuerMetadata, + context?: CredentialIssuanceContext, + ): Promise; +} diff --git a/packages/ows-types/src/credentials/oid4vp.ts b/packages/ows-types/src/credentials/oid4vp.ts new file mode 100644 index 0000000..5aa5b7c --- /dev/null +++ b/packages/ows-types/src/credentials/oid4vp.ts @@ -0,0 +1,38 @@ +import type { PresentationRequestUri } from "../primitives/index.js"; +import type { StoredCredential } from "./credential.js"; +import type { CredentialSummary } from "./filter.js"; +import type { IHolderSigner } from "./holder-signer.js"; +import type { + Oid4vpAuthorizationRequest, + PresentationDefinition, + PresentationResult, +} from "./presentation.js"; +import type { IWalletAttestationProvider } from "./wallet-attestation.js"; + +export type PresentationBuildContext = { + holderSigner: IHolderSigner; + /** Optional provider when verifier client_metadata requires attestation. */ + attestationProvider?: IWalletAttestationProvider; + /** Full auth request when resolved from HTTP `request_uri` (for response_uri / encryption). */ + authorizationRequest?: Oid4vpAuthorizationRequest; +}; + +export interface IOid4vpClient { + resolveRequest(uri: PresentationRequestUri): Promise; + /** + * Optional: return full authorization request after resolve (HTTP clients). + * Used for response_uri posting and encryption. + */ + getAuthorizationRequest?( + definitionId: string, + ): Oid4vpAuthorizationRequest | undefined; + matchCredentials( + definition: PresentationDefinition, + credentials: CredentialSummary[], + ): Promise; + buildPresentation( + credential: StoredCredential, + definition: PresentationDefinition, + context?: PresentationBuildContext, + ): Promise; +} diff --git a/packages/ows-types/src/credentials/presentation.ts b/packages/ows-types/src/credentials/presentation.ts new file mode 100644 index 0000000..25fba2b --- /dev/null +++ b/packages/ows-types/src/credentials/presentation.ts @@ -0,0 +1,80 @@ +import type { + CredentialClaimName, + CredentialIssuer, + CredentialTypeName, + PresentationRequestUri, + SdJwtVcPresentationString, + UriString, +} from "../primitives/index.js"; +import type { CredentialFormat } from "./format.js"; +import type { OwsDcqlQuery } from "./dcql.js"; + +export type PresentationDefinition = { + id: string; + verifier: { + id: UriString; + name: string; + }; + requestedClaims: CredentialClaimName[]; + credentialTypes?: CredentialTypeName[]; + nonce?: string; + audience?: string; +}; + +/** + * Transport / protocol fields from an OID4VP authorization request + * (fetched via `request_uri` or embedded). Wallet-facing claim selection + * still uses {@link PresentationDefinition}. + */ +export type Oid4vpResponseMode = "direct_post" | "direct_post.jwt" | "fragment"; + +export type Oid4vpClientMetadata = { + /** JWKS for encrypting the presentation response (direct_post.jwt). */ + jwks?: { + keys: JsonWebKey[]; + }; + jwks_uri?: string; + authorization_encrypted_response_alg?: string; + authorization_encrypted_response_enc?: string; + /** When true, wallet should attach a wallet attestation JWT. */ + require_wallet_attestation?: boolean; + client_name?: string; +}; + +export type Oid4vpAuthorizationRequest = { + clientId: string; + nonce?: string; + responseUri?: UriString; + responseMode?: Oid4vpResponseMode; + clientMetadata?: Oid4vpClientMetadata; + /** Raw DCQL query when the verifier uses DCQL instead of a claim-list PD. */ + dcqlQuery?: OwsDcqlQuery; + /** Mapped wallet-facing definition (always populated after resolve). */ + presentationDefinition: PresentationDefinition; +}; + +export type PresentationRequestInput = { + requestUri?: PresentationRequestUri; + request?: PresentationDefinition; + /** Host/verifier allow-list; wallet intersects with matched credentials. */ + acceptedIssuers?: CredentialIssuer[]; +}; + +export type PresentationResult = { + presentation: SdJwtVcPresentationString; + format: CredentialFormat; + disclosedClaims: CredentialClaimName[]; + /** When the VP was encrypted for `direct_post.jwt`. */ + encryptedResponse?: string; + responseMode?: Oid4vpResponseMode; + /** True when the client already POSTed to `response_uri`. */ + submittedToResponseUri?: boolean; +}; + +export type CredentialPresentationApprovalRequest = { + verifierName: string; + verifierId: UriString; + requestedClaims: CredentialClaimName[]; + credentialType: CredentialTypeName; + credentialIssuer: CredentialIssuer; +}; diff --git a/packages/ows-types/src/credentials/status-validator.ts b/packages/ows-types/src/credentials/status-validator.ts new file mode 100644 index 0000000..73d2d0a --- /dev/null +++ b/packages/ows-types/src/credentials/status-validator.ts @@ -0,0 +1,19 @@ +import { ISO8601DateTime } from "../primitives/index.js"; +import type { StoredCredential } from "./credential.js"; +import type { CredentialStatusCheck } from "./status.js"; + +/** Issuer-side status / revocation check (Token Status List, etc.). */ +export interface ICredentialStatusValidator { + checkStatus(credential: StoredCredential): Promise; +} + +/** Demo default — always active. Production wallets fail closed on non-active. */ +export class NoopCredentialStatusValidator implements ICredentialStatusValidator { + async checkStatus(credential: StoredCredential): Promise { + return { + credentialId: credential.credentialId, + status: "active", + checkedAt: ISO8601DateTime(new Date().toISOString()), + }; + } +} diff --git a/packages/ows-types/src/credentials/status.ts b/packages/ows-types/src/credentials/status.ts new file mode 100644 index 0000000..44932f6 --- /dev/null +++ b/packages/ows-types/src/credentials/status.ts @@ -0,0 +1,8 @@ +import type { CredentialId, ISO8601DateTime } from "../primitives/index.js"; + +export type CredentialStatusCheck = { + credentialId: CredentialId; + status: "active" | "revoked" | "suspended" | "unknown"; + checkedAt: ISO8601DateTime; + details?: string; +}; diff --git a/packages/ows-types/src/credentials/storage.ts b/packages/ows-types/src/credentials/storage.ts new file mode 100644 index 0000000..f522875 --- /dev/null +++ b/packages/ows-types/src/credentials/storage.ts @@ -0,0 +1,17 @@ +import type { CredentialId } from "../primitives/index.js"; +import type { StoredCredential } from "./credential.js"; +import type { CredentialFilter, CredentialSummary } from "./filter.js"; + +/** + * Branding-owned credential persistence. + * Methods take/return full {@link StoredCredential} objects (plaintext JSON at this boundary). + * Encryption at rest is a branding implementation detail. + */ +export interface ICredentialRepository { + store(credential: StoredCredential): Promise; + get(credentialId: CredentialId): Promise; + list(filter?: CredentialFilter): Promise; + delete(credentialId: CredentialId): Promise; + /** Local wallet lifecycle (tombstone / hide) — not issuer-side revocation. */ + revoke(credentialId: CredentialId): Promise; +} diff --git a/packages/ows-types/src/credentials/trust-registry.ts b/packages/ows-types/src/credentials/trust-registry.ts new file mode 100644 index 0000000..7f7af99 --- /dev/null +++ b/packages/ows-types/src/credentials/trust-registry.ts @@ -0,0 +1,12 @@ +import type { CredentialIssuer } from "../primitives/index.js"; +import type { IssuerTrustMetadata } from "./trust.js"; + +/** + * Branding-owned issuer trust. Confirms whether the wallet will store/use credentials + * from an already-identified issuer — not cryptographic issuer identity verification. + */ +export interface IIssuerTrustRegistry { + isTrustedIssuer(issuerId: CredentialIssuer): Promise; + resolveIssuer(issuerId: CredentialIssuer): Promise; + listIssuers(): Promise; +} diff --git a/packages/ows-types/src/credentials/trust.ts b/packages/ows-types/src/credentials/trust.ts new file mode 100644 index 0000000..a9cc252 --- /dev/null +++ b/packages/ows-types/src/credentials/trust.ts @@ -0,0 +1,12 @@ +import type { CredentialIssuer } from "../primitives/index.js"; + +export type AssuranceLevel = "low" | "substantial" | "high"; + +export type IssuerTrustMetadata = { + issuerId: CredentialIssuer; + name: string; + assuranceLevels: AssuranceLevel[]; + jurisdictions?: string[]; +}; + +export type TrustRegistryEntry = IssuerTrustMetadata; diff --git a/packages/ows-types/src/credentials/wallet-attestation.ts b/packages/ows-types/src/credentials/wallet-attestation.ts new file mode 100644 index 0000000..0000ecb --- /dev/null +++ b/packages/ows-types/src/credentials/wallet-attestation.ts @@ -0,0 +1,10 @@ +/** + * Produces a wallet attestation JWT when an issuer or verifier requires one + * (OID4VCI / OID4VP deployment profiles). + */ +export interface IWalletAttestationProvider { + createAttestation(input: { + audience: string; + nonce?: string; + }): Promise; +} diff --git a/packages/ows-types/src/eip1193/eip7715.ts b/packages/ows-types/src/eip1193/eip7715.ts new file mode 100644 index 0000000..2971fc4 --- /dev/null +++ b/packages/ows-types/src/eip1193/eip7715.ts @@ -0,0 +1,98 @@ +import type { + EVMAccountAddress, + EVMChainId, + EVMContractAddress, + HexString, +} from "../primitives/index.js"; + +/** + * EIP-7715 base permission object (permission type + adjustment flag + type-specific data). + * Concrete `type` / `data` shapes are defined by additional ERCs (e.g. erc20-token-periodic). + */ +export interface IExecutionPermission { + type: string; + isAdjustmentAllowed: boolean; + data: Record; +} + +/** EIP-7715 rule constraining a permission (e.g. expiry). */ +export interface IExecutionPermissionRule { + type: string; + data: Record; +} + +/** + * One appended caveat on an EIP-7715 permission request. + * + * The wire shape nests the caveat's config under `data` (consistent with + * `IExecutionPermission.data`), rather than the spread form + * `@metamask/smart-accounts-kit`'s `CoreCaveatConfiguration` uses internally + * (`{ type, ...config }`). At sign time the wallet maps `data` to the kit's + * caveat config via `builder.addCaveat(type, data)` and merges the result onto + * the top-level scope through `createDelegation({ scope, caveats })`. + * + * `type` matches a `CaveatType` value from `@metamask/smart-accounts-kit`. + * `data` carries the config for that caveat's builder (e.g. + * `{ startIndex: 4, value: "0x..." }` for `allowedCalldata`). + */ +export interface IAppendedCaveatConfiguration { + type: string; + data: Record; +} + +/** + * One entry in `wallet_requestExecutionPermissions` params. + * `params` is the array of these objects (not nested in a further wrapper). + */ +export interface IExecutionPermissionRequest { + chainId: EVMChainId; + /** Optional; wallet may choose the account when omitted. */ + from?: EVMAccountAddress; + /** Session / delegatee account that receives the permission. */ + to: EVMAccountAddress; + permission: IExecutionPermission; + rules?: IExecutionPermissionRule[]; + /** + * Appended caveats merged onto the top-level `permission` scope at sign time, + * mirroring `createDelegation({ scope, caveats })` in + * `@metamask/smart-accounts-kit`. Optional + additive: omitting it keeps the + * legacy single-scope behaviour. + */ + caveats?: IAppendedCaveatConfiguration[]; +} + +/** ERC-4337 factory dependency required before redeeming a permission. */ +export interface IExecutionPermissionDependency { + factory: EVMContractAddress; + factoryData: HexString; +} + +/** + * Grant response: request fields (possibly attenuated) plus redeem metadata. + * `context` is opaque to the host and is passed to ERC-7710 redemption. + */ +export interface IExecutionPermissionResponse extends IExecutionPermissionRequest { + context: HexString; + dependencies: IExecutionPermissionDependency[]; + delegationManager: EVMContractAddress; +} + +/** Params object for `wallet_revokeExecutionPermission`. */ +export interface IRevokeExecutionPermissionParams { + permissionContext: HexString; +} + +/** Per-permission-type support entry from `wallet_getSupportedExecutionPermissions`. */ +export interface ISupportedExecutionPermissionEntry { + chainIds: EVMChainId[]; + ruleTypes: string[]; +} + +/** + * Map of permission type → supported chains and rule types. + * Keys are permission type strings (e.g. `"erc20-token-periodic"`). + */ +export type SupportedExecutionPermissions = Record< + string, + ISupportedExecutionPermissionEntry +>; diff --git a/packages/ows-types/src/eip1193/index.ts b/packages/ows-types/src/eip1193/index.ts new file mode 100644 index 0000000..e206a88 --- /dev/null +++ b/packages/ows-types/src/eip1193/index.ts @@ -0,0 +1,29 @@ +export { + EIP1193_METHODS, + EIP1193_READ_METHODS, + EIP1193_UNRECOGNIZED_CHAIN_ID, + isEip1193Method, + type Eip1193Method, + type Eip1193ReadMethod, +} from "./methods.js"; + +export type { + EIP1193RequestArgs, + EIP1193RequestArgsFor, + EIP1193Requests, + KnownEIP1193Method, +} from "./requests.js"; + +export { IEVMTransactionRequestSchema, type IEVMTransactionRequest } from "./transaction.js"; + +export type { + IExecutionPermission, + IExecutionPermissionDependency, + IExecutionPermissionRequest, + IExecutionPermissionResponse, + IExecutionPermissionRule, + IAppendedCaveatConfiguration, + IRevokeExecutionPermissionParams, + ISupportedExecutionPermissionEntry, + SupportedExecutionPermissions, +} from "./eip7715.js"; diff --git a/packages/ows-types/src/eip1193/methods.ts b/packages/ows-types/src/eip1193/methods.ts new file mode 100644 index 0000000..41e6c7e --- /dev/null +++ b/packages/ows-types/src/eip1193/methods.ts @@ -0,0 +1,80 @@ +/** + * EIP-1193 wallet methods that Branding Layers typically implement + * (accounts, chain, signing). JSON-RPC read methods live in + * {@link EIP1193_READ_METHODS}. + */ +export const EIP1193_METHODS = [ + "eth_requestAccounts", + "eth_accounts", + "eth_chainId", + "personal_sign", + "eth_sign", + "eth_signTypedData", + "eth_signTypedData_v3", + "eth_signTypedData_v4", + "eth_sendTransaction", + "eth_signTransaction", + "wallet_switchEthereumChain", + "wallet_addEthereumChain", + "wallet_watchAsset", + // EIP-7715 — Request Permissions from Wallets + "wallet_requestExecutionPermissions", + "wallet_revokeExecutionPermission", + "wallet_getSupportedExecutionPermissions", + "wallet_getGrantedExecutionPermissions", +] as const; + +export type Eip1193Method = (typeof EIP1193_METHODS)[number]; + +export function isEip1193Method(method: string): method is Eip1193Method { + return (EIP1193_METHODS as readonly string[]).includes(method); +} + +/** + * Non-signing EIP-1193 methods commonly proxied to a JSON-RPC HTTP endpoint. + * Account / signing methods are intentionally omitted. + */ +export const EIP1193_READ_METHODS = [ + "eth_blockNumber", + "eth_call", + "eth_estimateGas", + "eth_createAccessList", + "eth_gasPrice", + "eth_feeHistory", + "eth_maxPriorityFeePerGas", + "eth_getBalance", + "eth_getCode", + "eth_getStorageAt", + "eth_getProof", + "eth_getTransactionCount", + "eth_getBlockByHash", + "eth_getBlockByNumber", + "eth_getBlockTransactionCountByHash", + "eth_getBlockTransactionCountByNumber", + "eth_getUncleByBlockHashAndIndex", + "eth_getUncleByBlockNumberAndIndex", + "eth_getUncleCountByBlockHash", + "eth_getUncleCountByBlockNumber", + "eth_getTransactionByHash", + "eth_getTransactionByBlockHashAndIndex", + "eth_getTransactionByBlockNumberAndIndex", + "eth_getTransactionReceipt", + "eth_getLogs", + "eth_getFilterChanges", + "eth_getFilterLogs", + "eth_newFilter", + "eth_newBlockFilter", + "eth_newPendingTransactionFilter", + "eth_uninstallFilter", + "eth_syncing", + "net_version", + "net_listening", + "net_peerCount", + "web3_clientVersion", + "web3_sha3", +] as const; + +export type Eip1193ReadMethod = (typeof EIP1193_READ_METHODS)[number]; + +/** MetaMask / EIP-1193 code for an unknown `wallet_switchEthereumChain` target. */ +export const EIP1193_UNRECOGNIZED_CHAIN_ID = 4902; diff --git a/packages/ows-types/src/eip1193/requests.ts b/packages/ows-types/src/eip1193/requests.ts new file mode 100644 index 0000000..d2e0f2f --- /dev/null +++ b/packages/ows-types/src/eip1193/requests.ts @@ -0,0 +1,76 @@ +import type { + EVMAccountAddress, + EVMChainId, + EVMSignatureHex, + EVMTransactionHash, +} from "../primitives/index.js"; +import type { + IExecutionPermissionRequest, + IExecutionPermissionResponse, + IRevokeExecutionPermissionParams, + SupportedExecutionPermissions, +} from "./eip7715.js"; +import type { IEVMTransactionRequest } from "./transaction.js"; + +/** + * Typed EIP-1193 methods supported by host `EIP1193Provider.request`. + * Add entries here (with branded params/results) instead of casting at call sites. + */ +export type EIP1193Requests = { + eth_requestAccounts: { + result: EVMAccountAddress[]; + }; + eth_accounts: { + result: EVMAccountAddress[]; + }; + eth_chainId: { + result: EVMChainId; + }; + personal_sign: { + params: readonly [message: string, address: EVMAccountAddress]; + result: EVMSignatureHex; + }; + eth_signTypedData_v4: { + params: readonly [address: EVMAccountAddress, typedData: unknown]; + result: EVMSignatureHex; + }; + eth_sendTransaction: { + params: readonly [IEVMTransactionRequest]; + result: EVMTransactionHash; + }; + wallet_switchEthereumChain: { + params: readonly [{ chainId: EVMChainId }]; + result: null; + }; + /** + * EIP-7715: `params` is the permission request array itself + * (not wrapped in an outer tuple element). + */ + wallet_requestExecutionPermissions: { + params: readonly IExecutionPermissionRequest[]; + result: IExecutionPermissionResponse[]; + }; + wallet_revokeExecutionPermission: { + params: readonly [IRevokeExecutionPermissionParams]; + result: null; + }; + wallet_getSupportedExecutionPermissions: { + result: SupportedExecutionPermissions; + }; + wallet_getGrantedExecutionPermissions: { + result: IExecutionPermissionResponse[]; + }; +}; + +export type KnownEIP1193Method = keyof EIP1193Requests; + +export type EIP1193RequestArgsFor = + EIP1193Requests[M] extends { params: infer P } + ? { method: M; params: P } + : { method: M; params?: readonly [] }; + +/** Fallback for wallet-specific or not-yet-mapped EIP-1193 methods. */ +export type EIP1193RequestArgs = { + method: string; + params?: readonly unknown[] | Record; +}; diff --git a/packages/ows-types/src/eip1193/transaction.ts b/packages/ows-types/src/eip1193/transaction.ts new file mode 100644 index 0000000..d703ab8 --- /dev/null +++ b/packages/ows-types/src/eip1193/transaction.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { EVMAccountAddressSchema } from "../primitives/EVMAccountAddress.js"; +import { EVMChainIdSchema } from "../primitives/EVMChainId.js"; +import { HexStringSchema } from "../primitives/HexString.js"; + +/** + * EIP-1193 `eth_sendTransaction` / `eth_signTransaction` request object. + * Quantities are hex-encoded per JSON-RPC. + */ +export const IEVMTransactionRequestSchema = z.object({ + from: EVMAccountAddressSchema.optional(), + to: EVMAccountAddressSchema.nullable().optional(), + gas: HexStringSchema.optional(), + gasPrice: HexStringSchema.optional(), + maxFeePerGas: HexStringSchema.optional(), + maxPriorityFeePerGas: HexStringSchema.optional(), + value: HexStringSchema.optional(), + data: HexStringSchema.optional(), + nonce: HexStringSchema.optional(), + chainId: EVMChainIdSchema.optional(), + type: z.string().optional(), + accessList: z.array(z.unknown()).optional(), +}); + +export type IEVMTransactionRequest = z.infer< + typeof IEVMTransactionRequestSchema +>; diff --git a/packages/ows-types/src/enums/EChainTechnology.ts b/packages/ows-types/src/enums/EChainTechnology.ts new file mode 100644 index 0000000..9de9843 --- /dev/null +++ b/packages/ows-types/src/enums/EChainTechnology.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +/** + * Chain technology / address family for multi-chain wallet UX. + */ +export enum EChainTechnology { + Evm = "evm", + Solana = "solana", + Bitcoin = "bitcoin", +} + +/** Zod schema: {@link EChainTechnology} string values. */ +export const EChainTechnologySchema = z.enum([ + EChainTechnology.Evm, + EChainTechnology.Solana, + EChainTechnology.Bitcoin, +]); diff --git a/packages/ows-types/src/enums/index.ts b/packages/ows-types/src/enums/index.ts new file mode 100644 index 0000000..695c427 --- /dev/null +++ b/packages/ows-types/src/enums/index.ts @@ -0,0 +1 @@ +export { EChainTechnology, EChainTechnologySchema } from "./EChainTechnology.js"; diff --git a/packages/ows-types/src/errors/base.ts b/packages/ows-types/src/errors/base.ts new file mode 100644 index 0000000..4d3c8c1 --- /dev/null +++ b/packages/ows-types/src/errors/base.ts @@ -0,0 +1,7 @@ +/** Base class for OWS SDK errors. */ +export class OwsError extends Error { + constructor(message: string) { + super(message); + this.name = "OwsError"; + } +} diff --git a/packages/ows-types/src/errors/rpc.ts b/packages/ows-types/src/errors/rpc.ts new file mode 100644 index 0000000..75a2e01 --- /dev/null +++ b/packages/ows-types/src/errors/rpc.ts @@ -0,0 +1,60 @@ +import { + RPC_ERROR_INVALID_PARAMS, + RPC_ERROR_UNIMPLEMENTED, + RPC_ERROR_USER_REJECTED, + type RpcErrorPayload, +} from "../protocol/wallet.js"; +import { OwsError } from "./base.js"; + +export class OwsRpcError extends OwsError { + constructor( + message: string, + readonly code: number, + readonly data?: unknown, + ) { + super(message); + this.name = "OwsRpcError"; + } + + toPayload(): RpcErrorPayload { + return { + code: this.code, + message: this.message, + data: this.data, + }; + } + + static fromPayload(payload: RpcErrorPayload): OwsRpcError { + if (payload.code === RPC_ERROR_UNIMPLEMENTED) { + return new OwsUnimplementedError(payload.message, payload.data); + } + if (payload.code === RPC_ERROR_INVALID_PARAMS) { + return new OwsInvalidParamsError(payload.message, payload.data); + } + if (payload.code === RPC_ERROR_USER_REJECTED) { + return new OwsUserRejectedError(payload.message, payload.data); + } + return new OwsRpcError(payload.message, payload.code, payload.data); + } +} + +export class OwsUnimplementedError extends OwsRpcError { + constructor(message = "Method not implemented", data?: unknown) { + super(message, RPC_ERROR_UNIMPLEMENTED, data); + this.name = "OwsUnimplementedError"; + } +} + +export class OwsInvalidParamsError extends OwsRpcError { + constructor(message = "Invalid params", data?: unknown) { + super(message, RPC_ERROR_INVALID_PARAMS, data); + this.name = "OwsInvalidParamsError"; + } +} + +export class OwsUserRejectedError extends OwsRpcError { + constructor(message = "User rejected the request", data?: unknown) { + super(message, RPC_ERROR_USER_REJECTED, data); + this.name = "OwsUserRejectedError"; + } +} \ No newline at end of file diff --git a/packages/ows-types/src/errors/signer.ts b/packages/ows-types/src/errors/signer.ts new file mode 100644 index 0000000..e05cab7 --- /dev/null +++ b/packages/ows-types/src/errors/signer.ts @@ -0,0 +1,43 @@ +import { OwsError } from "./base.js"; + +export class OwsSignerError extends OwsError { + constructor(message: string) { + super(message); + this.name = "OwsSignerError"; + } +} + +export class OwsNotAllowedError extends OwsSignerError { + constructor( + message: string, + readonly reason?: string, + ) { + super(message); + this.name = "OwsNotAllowedError"; + } +} + +/** + * User cancelled the Signing Layer passkey Confirm UI (before WebAuthn). + * Distinct from {@link OwsNotAllowedError} (WebAuthn cancel/policy) and + * Host-layer EIP-1193 user rejection (`OwsUserRejectedError`). + */ +export class OwsSignDeniedError extends OwsSignerError { + constructor( + message = "User denied the passkey ceremony", + readonly reason?: string, + ) { + super(message); + this.name = "OwsSignDeniedError"; + } +} + +export class OwsInvalidRequestError extends OwsSignerError { + constructor( + message: string, + readonly reason?: string, + ) { + super(message); + this.name = "OwsInvalidRequestError"; + } +} \ No newline at end of file diff --git a/packages/ows-types/src/index.ts b/packages/ows-types/src/index.ts new file mode 100644 index 0000000..b5ef616 --- /dev/null +++ b/packages/ows-types/src/index.ts @@ -0,0 +1,186 @@ +export { OwsError } from "./errors/base.js"; + +export { + OwsRpcError, + OwsUnimplementedError, + OwsInvalidParamsError, + OwsUserRejectedError, +} from "./errors/rpc.js"; + +export { + OwsSignerError, + OwsNotAllowedError, + OwsSignDeniedError, + OwsInvalidRequestError, +} from "./errors/signer.js"; + +export { + OWS_RPC_CALLBACK_EVENT, + RPC_ERROR_UNIMPLEMENTED, + RPC_ERROR_INVALID_PARAMS, + RPC_ERROR_USER_REJECTED, +} from "./protocol/wallet.js"; + +export { + OWS_REQUEST_DISPLAY_EVENT, + OWS_RELEASE_DISPLAY_EVENT, + OWS_REQUEST_HIDE_EVENT, + OWS_DISPLAY_READY_MODEL_METHOD, + OWS_HIDE_READY_MODEL_METHOD, + DEFAULT_DISPLAY_TIMEOUT_MS, + deserializeRequestDisplay, + deserializeDisplayReady, + deserializeReleaseDisplay, + deserializeRequestHide, + deserializeHideReady, +} from "./protocol/display.js"; + +export type { + RequestDisplayParams, + RequestDisplayEnvelope, + DisplayReadyPayload, + ReleaseDisplayEnvelope, + RequestHideEnvelope, + HideReadyPayload, +} from "./protocol/display.js"; + +export { + OWS_ANALYTICS_EVENT, + OWSAnalyticsEvent, + deserializeAnalyticsEvent, +} from "./protocol/analytics.js"; + +export type { + IOWSAnalyticsEvent, + OWSAnalyticsEventOptions, +} from "./protocol/analytics.js"; + +export { + OWS_EIP1193_EVENT, + deserializeEip1193EventNotification, +} from "./protocol/eip1193-events.js"; + +export type { IOWSEip1193EventNotification } from "./protocol/eip1193-events.js"; + +export type { + RpcErrorPayload, + RpcRequestEnvelope, + RpcResponseEnvelope, +} from "./protocol/wallet.js"; + +export { + serializeRpc, + deserializeRpcRequest, + deserializeRpcResponse, +} from "./protocol/serde.js"; + +export { API_VERSION } from "./protocol/signer.js"; + +export type { + SignScheme, + SignerMethod, + SignerEvent, + SignerRequest, + SignerEventMessage, + VersionData, + KeyDerivedData, + CredentialCreatedData, + DigestSignItem, + DigestSignedData, + DigestSignedResult, + SignDigestParams, + ExecuteBatchParams, + ExecuteBatchResult, + RecoveryDataCreatedData, + RecoverySessionStartedData, + RecoverySessionClearedData, + PublicKeyData, + WebAuthnAssertionFields, + ChallengeSignedData, + CeremonyUiParams, + CreateCredentialOptions, + GetPublicKeyParams, + EncryptAES256Params, + EncryptAES256Result, + DecryptAES256Params, + DecryptAES256Result, + RecoveryCeremonyOptions, +} from "./protocol/signer.js"; + +export type { IOWSSigner } from "./protocol/ows-signer.js"; + +export { + CREDENTIAL_WIRE_METHODS, + type CredentialWireMethod, + type OpenWalletCredentialProvider, +} from "./protocol/credentials.js"; + +export { + BITCOIN_WIRE_METHODS, + type BitcoinWireMethod, + type IBitcoinAccountAddressEntry, + type IBitcoinGetAccountAddressesParams, + type IOpenWalletBitcoinProvider, +} from "./protocol/bitcoin.js"; + +export { + SOLANA_WIRE_METHODS, + type SolanaWireMethod, + type ISolanaGetAccountAddressParams, + type ISolanaSignMessageParams, + type ISolanaSignMessageResult, + type ISolanaSignTransactionParams, + type ISolanaSignTransactionResult, + type ISolanaSignAndSendTransactionParams, + type ISolanaSignAndSendTransactionResult, + type ISolanaSignInParams, + type ISolanaSignInResult, + type IOpenWalletSolanaProvider, +} from "./protocol/solana.js"; + +export * from "./credentials/index.js"; + +export * from "./primitives/index.js"; + +export * from "./enums/index.js"; + +export { + EIP1193_METHODS, + EIP1193_READ_METHODS, + EIP1193_UNRECOGNIZED_CHAIN_ID, + isEip1193Method, + type Eip1193Method, + type Eip1193ReadMethod, + type EIP1193RequestArgs, + type EIP1193RequestArgsFor, + type EIP1193Requests, + type KnownEIP1193Method, + IEVMTransactionRequestSchema, + type IEVMTransactionRequest, + type IExecutionPermission, + type IExecutionPermissionDependency, + type IExecutionPermissionRequest, + type IExecutionPermissionResponse, + type IExecutionPermissionRule, + type IAppendedCaveatConfiguration, + type IRevokeExecutionPermissionParams, + type ISupportedExecutionPermissionEntry, + type SupportedExecutionPermissions, +} from "./eip1193/index.js"; + +export { + ChainUtils, + PresentationUtils, + ProofUtils, + ConversionUtils, + SolanaAddressUtils, + CredentialCryptoUtils, + type IOwsEd25519HolderSignerDeps, + type BuildSdJwtVcPresentationInput, + type BuildSdJwtVcPresentationResult, + type SdJwtVcIssuerClaims, + type KbJwtClaims, +} from "./utils/index.js"; + + + diff --git a/packages/ows-types/src/primitives/AES256CipherText.ts b/packages/ows-types/src/primitives/AES256CipherText.ts new file mode 100644 index 0000000..011c25d --- /dev/null +++ b/packages/ows-types/src/primitives/AES256CipherText.ts @@ -0,0 +1,19 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Raw AES-256-GCM ciphertext bytes as a `0x`-prefixed hex string + * (IV / version / packaging live on {@link AES256CipherTextEnvelope}). + * + * Example: `"0x" + ciphertext and tag hex` + */ +export type AES256CipherText = Brand<`0x${string}`, "AES256CipherText">; +export const AES256CipherText = make(); + +/** Zod schema: non-empty 0x-prefixed hex ciphertext. */ +export const AES256CipherTextSchema = z + .string() + .regex(/^0x[0-9a-fA-F]+$/, { + message: "must be a non-empty 0x-prefixed hex ciphertext", + }) + .transform((s) => AES256CipherText(s as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/AES256CipherTextEnvelope.ts b/packages/ows-types/src/primitives/AES256CipherTextEnvelope.ts new file mode 100644 index 0000000..dba3492 --- /dev/null +++ b/packages/ows-types/src/primitives/AES256CipherTextEnvelope.ts @@ -0,0 +1,22 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Signing Layer AES-256-GCM envelope: + * `ows-aes1:0x` ‖ version(1) ‖ IV(12) ‖ ciphertext+tag. + * + * Example: `"ows-aes1:0x01…"`. + */ +export type AES256CipherTextEnvelope = Brand< + string, + "AES256CipherTextEnvelope" +>; +export const AES256CipherTextEnvelope = make(); + +/** Zod schema: `ows-aes1:` prefix + 0x hex payload. */ +export const AES256CipherTextEnvelopeSchema = z + .string() + .regex(/^ows-aes1:0x[0-9a-fA-F]+$/, { + message: "must be an ows-aes1:0x… AES-256 envelope", + }) + .transform((s) => AES256CipherTextEnvelope(s)); diff --git a/packages/ows-types/src/primitives/AnalyticsEventId.ts b/packages/ows-types/src/primitives/AnalyticsEventId.ts new file mode 100644 index 0000000..799ed28 --- /dev/null +++ b/packages/ows-types/src/primitives/AnalyticsEventId.ts @@ -0,0 +1,15 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Client-generated UUID correlating a Branding→Host analytics notification. + * + * Example: `"550e8400-e29b-41d4-a716-446655440000"` + */ +export type AnalyticsEventId = Brand; +export const AnalyticsEventId = make(); + +/** Zod schema: UUID string. */ +export const AnalyticsEventIdSchema = z + .uuid({ message: "must be a UUID" }) + .transform((s) => AnalyticsEventId(s)); diff --git a/packages/ows-types/src/primitives/Base64String.ts b/packages/ows-types/src/primitives/Base64String.ts new file mode 100644 index 0000000..a68f730 --- /dev/null +++ b/packages/ows-types/src/primitives/Base64String.ts @@ -0,0 +1,22 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Standard base64-encoded string (`A–Z` / `a–z` / `0–9` / `+` / `/`, optional `=` padding). + * Distinct from {@link Base64UrlEncodedString} (JWT / URL-safe alphabet). + * + * Example: `"SGVsbG8gV29ybGQ="` + */ +export type Base64String = Brand; +export const Base64String = make(); + +const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +/** Zod schema: standard base64 (with optional padding). */ +export const Base64StringSchema = z + .string() + .min(1) + .refine((s) => BASE64_RE.test(s), { + message: "must be a base64-encoded string", + }) + .transform((s) => Base64String(s)); diff --git a/packages/ows-types/src/primitives/Base64UrlEncodedString.ts b/packages/ows-types/src/primitives/Base64UrlEncodedString.ts new file mode 100644 index 0000000..727a527 --- /dev/null +++ b/packages/ows-types/src/primitives/Base64UrlEncodedString.ts @@ -0,0 +1,21 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Base64url-encoded string (no padding required; `-` / `_` alphabet). + * + * Example: `"SGVsbG8"` + */ +export type Base64UrlEncodedString = Brand; +export const Base64UrlEncodedString = make(); + +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +/** Zod schema: base64url charset (A–Z, a–z, 0–9, `-`, `_`). */ +export const Base64UrlEncodedStringSchema = z + .string() + .min(1) + .refine((s) => BASE64URL_RE.test(s), { + message: "must be a base64url-encoded string", + }) + .transform((s) => Base64UrlEncodedString(s)); diff --git a/packages/ows-types/src/primitives/BitcoinChainId.ts b/packages/ows-types/src/primitives/BitcoinChainId.ts new file mode 100644 index 0000000..94fec21 --- /dev/null +++ b/packages/ows-types/src/primitives/BitcoinChainId.ts @@ -0,0 +1,28 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A Bitcoin chain id sentinel (`"Bitcoin"` mainnet, `"BitcoinTestnet"` testnet). + * + * Example: `"Bitcoin"` + */ +export type BitcoinChainId = Brand< + "Bitcoin" | "BitcoinTestnet", + "BitcoinChainId" +>; +export const BitcoinChainId = make(); + +export const BITCOIN_MAINNET_CHAIN_ID: BitcoinChainId = + BitcoinChainId("Bitcoin"); +export const BITCOIN_TESTNET_CHAIN_ID: BitcoinChainId = + BitcoinChainId("BitcoinTestnet"); + +export const BITCOIN_CAIP2 = { + MAINNET: "bip122:000000000019d6689c085ae165831e93", + TESTNET: "bip122:000000000933ea01ad0ee984209779ba", +} as const; + +/** Zod schema: Bitcoin chain sentinel. */ +export const BitcoinChainIdSchema = z + .enum(["Bitcoin", "BitcoinTestnet"]) + .transform((s) => BitcoinChainId(s)); diff --git a/packages/ows-types/src/primitives/BitcoinSegwitAccountAddress.ts b/packages/ows-types/src/primitives/BitcoinSegwitAccountAddress.ts new file mode 100644 index 0000000..48a87ab --- /dev/null +++ b/packages/ows-types/src/primitives/BitcoinSegwitAccountAddress.ts @@ -0,0 +1,41 @@ +import { bech32 } from "@scure/base"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A Bitcoin Native SegWit (P2WPKH) account address. + * Starts with `bc1q` on Bitcoin Mainnet or `tb1q` on Bitcoin Testnet. + * + * Example: `"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"` + */ +export type BitcoinSegwitAccountAddress = Brand< + string, + "BitcoinSegwitAccountAddress" +>; +export const BitcoinSegwitAccountAddress = make(); + +function isValidSegwitP2wpkh(input: string): boolean { + try { + const trimmed = input.trim().toLowerCase(); + const decoded = bech32.decode(trimmed); + if (decoded.prefix !== "bc" && decoded.prefix !== "tb") { + return false; + } + const version = decoded.words[0]; + if (version !== 0) { + return false; + } + const program = bech32.fromWords(decoded.words.slice(1)); + return program.length === 20; + } catch { + return false; + } +} + +/** Zod schema: Native SegWit P2WPKH (`bc1q` / `tb1q`), bech32-validated. */ +export const BitcoinSegwitAccountAddressSchema = z + .string() + .refine(isValidSegwitP2wpkh, { + message: "must be a Native SegWit P2WPKH address (bc1q / tb1q)", + }) + .transform((s) => BitcoinSegwitAccountAddress(s.trim().toLowerCase())); diff --git a/packages/ows-types/src/primitives/BitcoinSignatureHex.ts b/packages/ows-types/src/primitives/BitcoinSignatureHex.ts new file mode 100644 index 0000000..be53c53 --- /dev/null +++ b/packages/ows-types/src/primitives/BitcoinSignatureHex.ts @@ -0,0 +1,18 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A hex-encoded Bitcoin wire signature (DER signature + SIGHASH byte, e.g. SIGHASH_ALL `0x01`). + * + * Example: `"0x30440220…01"` + */ +export type BitcoinSignatureHex = Brand<`0x${string}`, "BitcoinSignatureHex">; +export const BitcoinSignatureHex = make(); + +/** Zod schema: 0x-prefixed hex Bitcoin wire signature. */ +export const BitcoinSignatureHexSchema = z + .string() + .regex(/^0x[0-9a-fA-F]+$/, { + message: "must be a 0x-prefixed hex Bitcoin signature", + }) + .transform((s) => BitcoinSignatureHex(s as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/BitcoinTransactionHash.ts b/packages/ows-types/src/primitives/BitcoinTransactionHash.ts new file mode 100644 index 0000000..162d3ad --- /dev/null +++ b/packages/ows-types/src/primitives/BitcoinTransactionHash.ts @@ -0,0 +1,19 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A 32-byte Bitcoin transaction hash / txid as a 64-character hex string + * **without** a `0x` prefix (Bitcoin wire convention; not a viem Hex type). + * + * Example: `"f007551f169722ce74104d6673bd46ce193c624b8550889526d1b93820d725f7"` + */ +export type BitcoinTransactionHash = Brand; +export const BitcoinTransactionHash = make(); + +/** Zod schema: 64 hex chars, no 0x prefix. */ +export const BitcoinTransactionHashSchema = z + .string() + .regex(/^[0-9a-fA-F]{64}$/, { + message: "must be a 32-byte hex Bitcoin txid without 0x prefix", + }) + .transform((s) => BitcoinTransactionHash(s.toLowerCase())); diff --git a/packages/ows-types/src/primitives/COSEPublicKey.ts b/packages/ows-types/src/primitives/COSEPublicKey.ts new file mode 100644 index 0000000..9ca9262 --- /dev/null +++ b/packages/ows-types/src/primitives/COSEPublicKey.ts @@ -0,0 +1,22 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * WebAuthn authenticator credential public key as COSE CBOR (base64url). + * Native form from attestation authenticator data (`credentialPublicKey`). + * + * Example: `"pQECAyYgASFYI…"` (base64url COSE key) + */ +export type COSEPublicKey = Brand; +export const COSEPublicKey = make(); + +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +/** Zod schema: base64url COSE public key. */ +export const COSEPublicKeySchema = z + .string() + .min(1) + .refine((s) => BASE64URL_RE.test(s), { + message: "must be a base64url COSE public key", + }) + .transform((s) => COSEPublicKey(s)); diff --git a/packages/ows-types/src/primitives/CredentialClaimName.ts b/packages/ows-types/src/primitives/CredentialClaimName.ts new file mode 100644 index 0000000..4200dae --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialClaimName.ts @@ -0,0 +1,16 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * SD-JWT claim name within a credential. + * + * Example: `"given_name"` + */ +export type CredentialClaimName = Brand; +export const CredentialClaimName = make(); + +/** Zod schema: non-empty claim name. */ +export const CredentialClaimNameSchema = z + .string() + .min(1, { message: "must be a non-empty claim name" }) + .transform((s) => CredentialClaimName(s)); diff --git a/packages/ows-types/src/primitives/CredentialConfigurationId.ts b/packages/ows-types/src/primitives/CredentialConfigurationId.ts new file mode 100644 index 0000000..226ab32 --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialConfigurationId.ts @@ -0,0 +1,19 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * OID4VCI credential configuration identifier. + * + * Example: `"KycCredential"` + */ +export type CredentialConfigurationId = Brand< + string, + "CredentialConfigurationId" +>; +export const CredentialConfigurationId = make(); + +/** Zod schema: non-empty OID4 credential configuration id. */ +export const CredentialConfigurationIdSchema = z + .string() + .min(1, { message: "must be a non-empty credential configuration id" }) + .transform((s) => CredentialConfigurationId(s)); diff --git a/packages/ows-types/src/primitives/CredentialFormatId.ts b/packages/ows-types/src/primitives/CredentialFormatId.ts new file mode 100644 index 0000000..e8fccf0 --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialFormatId.ts @@ -0,0 +1,16 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Credential encoding format identifier from issuer metadata. + * + * Example: `"sd-jwt-vc"` + */ +export type CredentialFormatId = Brand; +export const CredentialFormatId = make(); + +/** Zod schema: non-empty OID4 format id. */ +export const CredentialFormatIdSchema = z + .string() + .min(1, { message: "must be a non-empty credential format id" }) + .transform((s) => CredentialFormatId(s)); diff --git a/packages/ows-types/src/primitives/CredentialId.ts b/packages/ows-types/src/primitives/CredentialId.ts new file mode 100644 index 0000000..fc10b57 --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialId.ts @@ -0,0 +1,17 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Stable identifier for a credential held in the wallet store + * (WebAuthn credential id or store-local id). + * + * Example: `"cred_1719792000_ab12cd"` + */ +export type CredentialId = Brand; +export const CredentialId = make(); + +/** Zod schema: non-empty credential id. */ +export const CredentialIdSchema = z + .string() + .min(1, { message: "must be a non-empty credential id" }) + .transform((s) => CredentialId(s)); diff --git a/packages/ows-types/src/primitives/CredentialIssuer.ts b/packages/ows-types/src/primitives/CredentialIssuer.ts new file mode 100644 index 0000000..39b5cbb --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialIssuer.ts @@ -0,0 +1,16 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Credential issuer identifier (typically an HTTPS origin or DID). + * + * Example: `"https://issuer.example"` + */ +export type CredentialIssuer = Brand; +export const CredentialIssuer = make(); + +/** Zod schema: non-empty credential issuer id. */ +export const CredentialIssuerSchema = z + .string() + .min(1, { message: "must be a non-empty credential issuer" }) + .transform((s) => CredentialIssuer(s)); diff --git a/packages/ows-types/src/primitives/CredentialOfferUri.ts b/packages/ows-types/src/primitives/CredentialOfferUri.ts new file mode 100644 index 0000000..54eadbc --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialOfferUri.ts @@ -0,0 +1,29 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * URI referencing an OID4VCI credential offer (HTTPS, `openid-credential-offer:`, or mock scheme). + * + * Example: `"openid-credential-offer://?credential_offer=…"` + */ +export type CredentialOfferUri = Brand; +export const CredentialOfferUri = make(); + +function isUriLike(s: string): boolean { + if (!s.trim()) { + return false; + } + try { + // eslint-disable-next-line no-new + new URL(s); + return true; + } catch { + return /^[a-z][a-z0-9+.-]*:/i.test(s); + } +} + +/** Zod schema: credential offer URI (http(s) or custom scheme). */ +export const CredentialOfferUriSchema = z + .string() + .refine(isUriLike, { message: "must be a valid credential offer URI" }) + .transform((s) => CredentialOfferUri(s)); diff --git a/packages/ows-types/src/primitives/CredentialScope.ts b/packages/ows-types/src/primitives/CredentialScope.ts new file mode 100644 index 0000000..204bf75 --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialScope.ts @@ -0,0 +1,16 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * OID4 / credential scope string. + * + * Example: `"openid"` + */ +export type CredentialScope = Brand; +export const CredentialScope = make(); + +/** Zod schema: non-empty credential scope. */ +export const CredentialScopeSchema = z + .string() + .min(1, { message: "must be a non-empty credential scope" }) + .transform((s) => CredentialScope(s)); diff --git a/packages/ows-types/src/primitives/CredentialTypeName.ts b/packages/ows-types/src/primitives/CredentialTypeName.ts new file mode 100644 index 0000000..6305674 --- /dev/null +++ b/packages/ows-types/src/primitives/CredentialTypeName.ts @@ -0,0 +1,16 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * SD-JWT / VC type name. + * + * Example: `"VerifiableCredential"` + */ +export type CredentialTypeName = Brand; +export const CredentialTypeName = make(); + +/** Zod schema: non-empty credential type name. */ +export const CredentialTypeNameSchema = z + .string() + .min(1, { message: "must be a non-empty credential type name" }) + .transform((s) => CredentialTypeName(s)); diff --git a/packages/ows-types/src/primitives/DisplayRequestId.ts b/packages/ows-types/src/primitives/DisplayRequestId.ts new file mode 100644 index 0000000..b7d7213 --- /dev/null +++ b/packages/ows-types/src/primitives/DisplayRequestId.ts @@ -0,0 +1,17 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Correlates a branding-layer display request with the host response. + * Typically a monotonic decimal string from the branding session counter. + * + * Example: `"1"` + */ +export type DisplayRequestId = Brand; +export const DisplayRequestId = make(); + +/** Zod schema: non-empty display request id string. */ +export const DisplayRequestIdSchema = z + .string() + .min(1, { message: "must be a non-empty display request id" }) + .transform((s) => DisplayRequestId(s)); diff --git a/packages/ows-types/src/primitives/DomainString.ts b/packages/ows-types/src/primitives/DomainString.ts new file mode 100644 index 0000000..49da689 --- /dev/null +++ b/packages/ows-types/src/primitives/DomainString.ts @@ -0,0 +1,21 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * DNS hostname / domain without scheme or path (e.g. `example.com`, `app.example.com`, + * `localhost`). Does not include a protocol or path — use {@link UriString} for full + * URLs such as `https://example.com/foo`. + * + * Example: `"wallet.1shotapi.com"` + */ +export type DomainString = Brand; +export const DomainString = make(); + +/** Zod schema: non-empty hostname / domain (no scheme). */ +export const DomainStringSchema = z + .string() + .min(1, { message: "must be a non-empty domain" }) + .refine((s) => !s.includes("://") && !s.includes("/"), { + message: "must be a hostname without scheme or path", + }) + .transform((s) => DomainString(s)); diff --git a/packages/ows-types/src/primitives/ED25519PublicKey.ts b/packages/ows-types/src/primitives/ED25519PublicKey.ts new file mode 100644 index 0000000..94d2d6c --- /dev/null +++ b/packages/ows-types/src/primitives/ED25519PublicKey.ts @@ -0,0 +1,18 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Hex-encoded Ed25519 public key (`0x` + 32 bytes / 64 hex chars). + * + * Example: `"0x" + 64 hex characters` + */ +export type ED25519PublicKey = Brand<`0x${string}`, "ED25519PublicKey">; +export const ED25519PublicKey = make(); + +/** Zod schema: 0x-prefixed 32-byte Ed25519 public key. */ +export const ED25519PublicKeySchema = z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/i, { + message: "must be a 32-byte 0x hex Ed25519 public key", + }) + .transform((s) => ED25519PublicKey(s.toLowerCase() as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/EVMAccountAddress.ts b/packages/ows-types/src/primitives/EVMAccountAddress.ts new file mode 100644 index 0000000..6225c57 --- /dev/null +++ b/packages/ows-types/src/primitives/EVMAccountAddress.ts @@ -0,0 +1,26 @@ +import { type Brand, make } from "ts-brand"; +import { getAddress } from "viem"; +import { z } from "zod"; + +/** + * EVM account address as an EIP-55 checksummed `0x`-prefixed hex string + * (mixed-case, 40 hex digits). Prefer constructing via + * {@link EVMAccountAddressSchema} or `AddressUtils.validateEVMAddress`. + * + * Example: `"0xAbCdEf0123456789AbCdEf0123456789aBcDeF01"` + */ +export type EVMAccountAddress = Brand<`0x${string}`, "EVMAccountAddress">; +export const EVMAccountAddress = make(); + +/** Zod schema: 20-byte hex address, normalized to EIP-55 checksum. */ +export const EVMAccountAddressSchema = z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/, { message: "must be a 20-byte 0x hex address" }) + .transform((s, ctx) => { + try { + return EVMAccountAddress(getAddress(s as `0x${string}`)); + } catch { + ctx.addIssue({ code: "custom", message: "invalid EIP-55 address" }); + return z.NEVER; + } + }); diff --git a/packages/ows-types/src/primitives/EVMChainId.ts b/packages/ows-types/src/primitives/EVMChainId.ts new file mode 100644 index 0000000..3e3dffb --- /dev/null +++ b/packages/ows-types/src/primitives/EVMChainId.ts @@ -0,0 +1,19 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * EIP-155 chain id as a `0x`-prefixed hex string (EIP-1193 `eth_chainId` form). + * Schemas normalize leading zeros so `"0x01"` and `"0x1"` compare equal. + * + * Example: `"0x1"` (Ethereum mainnet), `"0x13b2"` (Arc) + */ +export type EVMChainId = Brand<`0x${string}`, "EVMChainId">; +export const EVMChainId = make(); + +/** Zod schema: 0x hex chain id, normalized (no leading zeros in the hex body). */ +export const EVMChainIdSchema = z + .string() + .regex(/^0x[0-9a-fA-F]+$/, { message: "must be a 0x-prefixed hex chain id" }) + .transform((value) => + EVMChainId(`0x${BigInt(value).toString(16)}` as `0x${string}`), + ); diff --git a/packages/ows-types/src/primitives/EVMContractAddress.ts b/packages/ows-types/src/primitives/EVMContractAddress.ts new file mode 100644 index 0000000..1be24ad --- /dev/null +++ b/packages/ows-types/src/primitives/EVMContractAddress.ts @@ -0,0 +1,27 @@ +import { type Brand, make } from "ts-brand"; +import { getAddress } from "viem"; +import { z } from "zod"; + +/** + * EVM smart-contract address as an EIP-55 checksummed `0x`-prefixed hex string + * (mixed-case, 40 hex digits). Same address set as {@link EVMAccountAddress}, + * but branded separately so call sites do not mix EOAs / smart accounts with + * contract deployments (enforcers, factories, tokens, etc.). + * + * Example: `"0xAbCdEf0123456789AbCdEf0123456789aBcDeF01"` + */ +export type EVMContractAddress = Brand<`0x${string}`, "EVMContractAddress">; +export const EVMContractAddress = make(); + +/** Zod schema: 20-byte hex address, normalized to EIP-55 checksum. */ +export const EVMContractAddressSchema = z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/, { message: "must be a 20-byte 0x hex address" }) + .transform((s, ctx) => { + try { + return EVMContractAddress(getAddress(s as `0x${string}`)); + } catch { + ctx.addIssue({ code: "custom", message: "invalid EIP-55 address" }); + return z.NEVER; + } + }); diff --git a/packages/ows-types/src/primitives/EVMSignatureHex.ts b/packages/ows-types/src/primitives/EVMSignatureHex.ts new file mode 100644 index 0000000..33a4eac --- /dev/null +++ b/packages/ows-types/src/primitives/EVMSignatureHex.ts @@ -0,0 +1,19 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A hex-encoded ECDSA signature (EIP-191, EIP-712, etc.). + * Typically `0x` + 65-byte secp256k1 signature (130 hex chars). + * + * Example: `"0x" + 130 hex characters` + */ +export type EVMSignatureHex = Brand<`0x${string}`, "EVMSignatureHex">; +export const EVMSignatureHex = make(); + +/** Zod schema: 0x-prefixed hex signature (min 65 bytes / length ≥ 132). */ +export const EVMSignatureHexSchema = z + .string() + .refine((s) => /^0x[0-9a-fA-F]+$/.test(s) && s.length >= 132, { + message: "must be 0x-prefixed hex signature (min 65 bytes)", + }) + .transform((s) => EVMSignatureHex(s as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/EVMTransactionHash.ts b/packages/ows-types/src/primitives/EVMTransactionHash.ts new file mode 100644 index 0000000..8d7b2d2 --- /dev/null +++ b/packages/ows-types/src/primitives/EVMTransactionHash.ts @@ -0,0 +1,18 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A 32-byte Ethereum transaction hash (`0x` + 64 hex chars). + * + * Example: `"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"` + */ +export type EVMTransactionHash = Brand<`0x${string}`, "EVMTransactionHash">; +export const EVMTransactionHash = make(); + +/** Zod schema: 0x-prefixed 32-byte hex hash. */ +export const EVMTransactionHashSchema = z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/, { + message: "must be a 32-byte 0x hex transaction hash", + }) + .transform((s) => EVMTransactionHash(s as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/HexString.ts b/packages/ows-types/src/primitives/HexString.ts new file mode 100644 index 0000000..bce5fe3 --- /dev/null +++ b/packages/ows-types/src/primitives/HexString.ts @@ -0,0 +1,16 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * `0x`-prefixed hex-encoded byte string (empty payload `0x` allowed). + * + * Example: `"0xdeadbeef"` + */ +export type HexString = Brand<`0x${string}`, "HexString">; +export const HexString = make(); + +/** Zod schema: `0x` + hex digits (may be empty after prefix). */ +export const HexStringSchema = z + .string() + .regex(/^0x[0-9a-fA-F]*$/, { message: "must be 0x-prefixed hex" }) + .transform((s) => HexString(s as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/ISO8601DateTime.ts b/packages/ows-types/src/primitives/ISO8601DateTime.ts new file mode 100644 index 0000000..78777bb --- /dev/null +++ b/packages/ows-types/src/primitives/ISO8601DateTime.ts @@ -0,0 +1,19 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * ISO 8601 date-time string. + * + * Example: `"2026-07-07T23:21:00.000Z"` + */ +export type ISO8601DateTime = Brand; +export const ISO8601DateTime = make(); + +/** Zod schema: non-empty string with a finite `Date.parse` value. */ +export const ISO8601DateTimeSchema = z + .string() + .min(1) + .refine((s) => Number.isFinite(Date.parse(s)), { + message: "must be a valid ISO 8601 date-time", + }) + .transform((s) => ISO8601DateTime(s)); diff --git a/packages/ows-types/src/primitives/JSONString.ts b/packages/ows-types/src/primitives/JSONString.ts new file mode 100644 index 0000000..30d0120 --- /dev/null +++ b/packages/ows-types/src/primitives/JSONString.ts @@ -0,0 +1,26 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A string that parses as JSON (object or array JSON text). + * + * Example: `'{"foo":"bar"}'` + */ +export type JSONString = Brand; +export const JSONString = make(); + +/** Zod schema: string that `JSON.parse` accepts. */ +export const JSONStringSchema = z + .string() + .refine( + (s) => { + try { + JSON.parse(s); + return true; + } catch { + return false; + } + }, + { message: "must be valid JSON" }, + ) + .transform((s) => JSONString(s)); diff --git a/packages/ows-types/src/primitives/JWKThumbprint.ts b/packages/ows-types/src/primitives/JWKThumbprint.ts new file mode 100644 index 0000000..2d7e125 --- /dev/null +++ b/packages/ows-types/src/primitives/JWKThumbprint.ts @@ -0,0 +1,21 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * RFC 7638 JWK Thumbprint (SHA-256), base64url-encoded. + * + * Example: `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"` + */ +export type JWKThumbprint = Brand; +export const JWKThumbprint = make(); + +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +/** Zod schema: base64url JWK thumbprint. */ +export const JWKThumbprintSchema = z + .string() + .min(1) + .refine((s) => BASE64URL_RE.test(s), { + message: "must be a base64url JWK thumbprint", + }) + .transform((s) => JWKThumbprint(s)); diff --git a/packages/ows-types/src/primitives/OWSChainId.ts b/packages/ows-types/src/primitives/OWSChainId.ts new file mode 100644 index 0000000..503b69c --- /dev/null +++ b/packages/ows-types/src/primitives/OWSChainId.ts @@ -0,0 +1,23 @@ +import type { BitcoinChainId } from "./BitcoinChainId.js"; +import { BitcoinChainIdSchema } from "./BitcoinChainId.js"; +import type { EVMChainId } from "./EVMChainId.js"; +import { EVMChainIdSchema } from "./EVMChainId.js"; +import type { SolanaChainId } from "./SolanaChainId.js"; +import { SolanaChainIdSchema } from "./SolanaChainId.js"; +import { z } from "zod"; + +/** + * Union of supported chain ID types across all chain technologies. + * EVM uses EIP-155 hex (`0x…`); Bitcoin uses descriptive sentinels; + * Solana uses Wallet Standard / CAIP-2 (`solana:mainnet`, …). + * + * Example: `"0x1"` | `"Bitcoin"` | `"solana:mainnet"` + */ +export type OWSChainId = EVMChainId | BitcoinChainId | SolanaChainId; + +/** Zod schema: EVM, Bitcoin, or Solana chain id. */ +export const OWSChainIdSchema = z.union([ + EVMChainIdSchema, + BitcoinChainIdSchema, + SolanaChainIdSchema, +]); diff --git a/packages/ows-types/src/primitives/PresentationRequestUri.ts b/packages/ows-types/src/primitives/PresentationRequestUri.ts new file mode 100644 index 0000000..d0edb5c --- /dev/null +++ b/packages/ows-types/src/primitives/PresentationRequestUri.ts @@ -0,0 +1,29 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * URI referencing an OID4VP presentation request. + * + * Example: `"https://verifier.example/request/abc"` + */ +export type PresentationRequestUri = Brand; +export const PresentationRequestUri = make(); + +function isUriLike(s: string): boolean { + if (!s.trim()) { + return false; + } + try { + // eslint-disable-next-line no-new + new URL(s); + return true; + } catch { + return /^[a-z][a-z0-9+.-]*:/i.test(s); + } +} + +/** Zod schema: presentation request URI. */ +export const PresentationRequestUriSchema = z + .string() + .refine(isUriLike, { message: "must be a valid presentation request URI" }) + .transform((s) => PresentationRequestUri(s)); diff --git a/packages/ows-types/src/primitives/RPCCallId.ts b/packages/ows-types/src/primitives/RPCCallId.ts new file mode 100644 index 0000000..e9ea803 --- /dev/null +++ b/packages/ows-types/src/primitives/RPCCallId.ts @@ -0,0 +1,17 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A monotonically increasing number for RPC calls. + * + * Example: `1` + */ +export type RPCCallId = Brand; +export const RPCCallId = make(); + +/** Zod schema: non-negative integer RPC call id. */ +export const RPCCallIdSchema = z + .number() + .int() + .nonnegative() + .transform((n) => RPCCallId(n)); diff --git a/packages/ows-types/src/primitives/RelayerTransactionId.ts b/packages/ows-types/src/primitives/RelayerTransactionId.ts new file mode 100644 index 0000000..1d7c27b --- /dev/null +++ b/packages/ows-types/src/primitives/RelayerTransactionId.ts @@ -0,0 +1,18 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Opaque id returned by a 1Shot (or interim) relayer submit — `0x`-prefixed hex. + * + * Example: `"0xdeadbeef…"` + */ +export type RelayerTransactionId = Brand<`0x${string}`, "RelayerTransactionId">; +export const RelayerTransactionId = make(); + +/** Zod schema: non-empty 0x-prefixed hex relayer task id. */ +export const RelayerTransactionIdSchema = z + .string() + .regex(/^0x[0-9a-fA-F]+$/, { + message: "must be a non-empty 0x-prefixed hex relayer transaction id", + }) + .transform((s) => RelayerTransactionId(s as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/SECP256K1PublicKey.ts b/packages/ows-types/src/primitives/SECP256K1PublicKey.ts new file mode 100644 index 0000000..7dfb80d --- /dev/null +++ b/packages/ows-types/src/primitives/SECP256K1PublicKey.ts @@ -0,0 +1,20 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Hex-encoded **uncompressed** secp256k1 public key (`0x04` ‖ X ‖ Y = 65 bytes). + * Produced by the Signing Layer via `secpGetPublicKey(priv, false)` for viem + * `publicKeyToAddress`; compress before Bitcoin P2WPKH derivation. + * + * Example: `"0x04" + 128 hex characters` + */ +export type SECP256K1PublicKey = Brand<`0x${string}`, "SECP256K1PublicKey">; +export const SECP256K1PublicKey = make(); + +/** Zod schema: uncompressed secp256k1 public key (`0x04` + 64 bytes). */ +export const SECP256K1PublicKeySchema = z + .string() + .regex(/^0x04[0-9a-fA-F]{128}$/i, { + message: "must be uncompressed secp256k1 public key (0x04 + 64 bytes)", + }) + .transform((s) => SECP256K1PublicKey(s.toLowerCase() as `0x${string}`)); diff --git a/packages/ows-types/src/primitives/SPKIPublicKey.ts b/packages/ows-types/src/primitives/SPKIPublicKey.ts new file mode 100644 index 0000000..10c4812 --- /dev/null +++ b/packages/ows-types/src/primitives/SPKIPublicKey.ts @@ -0,0 +1,23 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * SubjectPublicKeyInfo (SPKI) DER public key (base64url). + * Browser `AuthenticatorAttestationResponse.getPublicKey()` form; + * suitable for Web Crypto `importKey` / Node `createPublicKey`. + * + * Example: `"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…"` (base64url SPKI) + */ +export type SPKIPublicKey = Brand; +export const SPKIPublicKey = make(); + +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +/** Zod schema: base64url SPKI public key. */ +export const SPKIPublicKeySchema = z + .string() + .min(1) + .refine((s) => BASE64URL_RE.test(s), { + message: "must be a base64url SPKI public key", + }) + .transform((s) => SPKIPublicKey(s)); diff --git a/packages/ows-types/src/primitives/SdJwtVcPresentationString.ts b/packages/ows-types/src/primitives/SdJwtVcPresentationString.ts new file mode 100644 index 0000000..a52e753 --- /dev/null +++ b/packages/ows-types/src/primitives/SdJwtVcPresentationString.ts @@ -0,0 +1,20 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * SD-JWT VC compact serialization for OID4VP presentation + * (`issuer-jwt~disclosure~…~kb+jwt`). + * + * Example: `"eyJhbGciOiJFUzI1NiJ9.…~WyJ…~"` + */ +export type SdJwtVcPresentationString = Brand< + string, + "SdJwtVcPresentationString" +>; +export const SdJwtVcPresentationString = make(); + +/** Zod schema: non-empty SD-JWT VC presentation string. */ +export const SdJwtVcPresentationStringSchema = z + .string() + .min(1, { message: "must be a non-empty SD-JWT VC presentation" }) + .transform((s) => SdJwtVcPresentationString(s)); diff --git a/packages/ows-types/src/primitives/SolanaATAAddress.ts b/packages/ows-types/src/primitives/SolanaATAAddress.ts new file mode 100644 index 0000000..5511267 --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaATAAddress.ts @@ -0,0 +1,30 @@ +import { address, type Address } from "@solana/addresses"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A base58-encoded Solana **associated token account** (ATA) address. + * Same wire shape as {@link SolanaAccountAddress} / {@link SolanaMintAddress}; + * branded separately so mint catalogs, wallets, and ATAs stay distinct + * (e.g. CCTP `mintRecipient`, tracked-asset ATA fields). + */ +export type SolanaATAAddress = Brand; +export const SolanaATAAddress = make(); + +/** Zod schema: Solana ATA address validated via `@solana/addresses`. */ +export const SolanaATAAddressSchema = z.string().transform((s, ctx) => { + try { + return SolanaATAAddress(address(s.trim()) as string); + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a base58 Solana ATA address (32-byte public key)", + }); + return z.NEVER; + } +}); + +/** Coerce a branded ATA address to Kit {@link Address}. */ +export function solanaAtaAddressToKit(value: SolanaATAAddress): Address { + return address(value); +} diff --git a/packages/ows-types/src/primitives/SolanaAccountAddress.ts b/packages/ows-types/src/primitives/SolanaAccountAddress.ts new file mode 100644 index 0000000..9877acd --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaAccountAddress.ts @@ -0,0 +1,33 @@ +import { address, type Address } from "@solana/addresses"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A base58-encoded Solana account address (32-byte ed25519 public key). + * Same wire shape as Kit {@link Address}; branded so call sites do not mix + * wallets with program ids or mints. + * + * Example: `"4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"` + */ +export type SolanaAccountAddress = Brand; +export const SolanaAccountAddress = make(); + +/** Zod schema: Solana base58 address validated via `@solana/addresses`. */ +export const SolanaAccountAddressSchema = z.string().transform((s, ctx) => { + try { + return SolanaAccountAddress(address(s.trim()) as string); + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a base58 Solana address (32-byte public key)", + }); + return z.NEVER; + } +}); + +/** Coerce a branded account address to Kit {@link Address}. */ +export function solanaAccountAddressToKit( + value: SolanaAccountAddress, +): Address { + return address(value); +} diff --git a/packages/ows-types/src/primitives/SolanaChainId.ts b/packages/ows-types/src/primitives/SolanaChainId.ts new file mode 100644 index 0000000..b3e42b2 --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaChainId.ts @@ -0,0 +1,35 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A Solana cluster id as a Wallet Standard / CAIP-2 sentinel. + * + * Example: `"solana:mainnet"` + */ +export type SolanaChainId = Brand< + "solana:mainnet" | "solana:devnet" | "solana:testnet", + "SolanaChainId" +>; +export const SolanaChainId = make(); + +export const SOLANA_MAINNET_CHAIN_ID: SolanaChainId = + SolanaChainId("solana:mainnet"); +export const SOLANA_DEVNET_CHAIN_ID: SolanaChainId = + SolanaChainId("solana:devnet"); +export const SOLANA_TESTNET_CHAIN_ID: SolanaChainId = + SolanaChainId("solana:testnet"); + +/** + * CAIP-2 / Wallet Standard cluster identifiers. + * Identical to the branded sentinels (kept for parity with {@link BITCOIN_CAIP2}). + */ +export const SOLANA_CAIP2 = { + MAINNET: "solana:mainnet", + DEVNET: "solana:devnet", + TESTNET: "solana:testnet", +} as const; + +/** Zod schema: Solana CAIP-2 cluster sentinel. */ +export const SolanaChainIdSchema = z + .enum(["solana:mainnet", "solana:devnet", "solana:testnet"]) + .transform((s) => SolanaChainId(s)); diff --git a/packages/ows-types/src/primitives/SolanaMintAddress.ts b/packages/ows-types/src/primitives/SolanaMintAddress.ts new file mode 100644 index 0000000..e271a59 --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaMintAddress.ts @@ -0,0 +1,31 @@ +import { address, type Address } from "@solana/addresses"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A base58-encoded Solana **mint** account (SPL Token / Token-2022). + * Same wire shape as {@link SolanaAccountAddress}; branded separately so asset + * catalogs do not mix mints with user wallets or program ids. + * + * Example (mainnet USDC): `"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"` + */ +export type SolanaMintAddress = Brand; +export const SolanaMintAddress = make(); + +/** Zod schema: Solana mint address validated via `@solana/addresses`. */ +export const SolanaMintAddressSchema = z.string().transform((s, ctx) => { + try { + return SolanaMintAddress(address(s.trim()) as string); + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a base58 Solana mint address (32-byte public key)", + }); + return z.NEVER; + } +}); + +/** Coerce a branded mint address to Kit {@link Address}. */ +export function solanaMintAddressToKit(value: SolanaMintAddress): Address { + return address(value); +} diff --git a/packages/ows-types/src/primitives/SolanaProgramAddress.ts b/packages/ows-types/src/primitives/SolanaProgramAddress.ts new file mode 100644 index 0000000..06f65a3 --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaProgramAddress.ts @@ -0,0 +1,33 @@ +import { address, type Address } from "@solana/addresses"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A base58-encoded Solana **program** id (executable account). + * Same wire shape as {@link SolanaAccountAddress}; branded separately so call + * sites do not mix user wallets with System / Token / ATA / CCTP programs. + * + * Example: `"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"` + */ +export type SolanaProgramAddress = Brand; +export const SolanaProgramAddress = make(); + +/** Zod schema: Solana program id validated via `@solana/addresses`. */ +export const SolanaProgramAddressSchema = z.string().transform((s, ctx) => { + try { + return SolanaProgramAddress(address(s.trim()) as string); + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a base58 Solana program address (32-byte public key)", + }); + return z.NEVER; + } +}); + +/** Coerce a branded program address to Kit {@link Address}. */ +export function solanaProgramAddressToKit( + value: SolanaProgramAddress, +): Address { + return address(value); +} diff --git a/packages/ows-types/src/primitives/SolanaSignature.ts b/packages/ows-types/src/primitives/SolanaSignature.ts new file mode 100644 index 0000000..a98f7e6 --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaSignature.ts @@ -0,0 +1,28 @@ +import { base58 } from "@scure/base"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A base58-encoded 64-byte Ed25519 signature (Solana message / tx signature wire). + * + * Example: `"5VERv8NMvzbJ…"` (length varies with base58 encoding of 64 bytes) + */ +export type SolanaSignature = Brand; +export const SolanaSignature = make(); + +function isValidSolanaSignature(input: string): boolean { + const trimmed = input.trim(); + try { + return base58.decode(trimmed).length === 64; + } catch { + return false; + } +} + +/** Zod schema: base58 Ed25519 signature decoding to 64 bytes. */ +export const SolanaSignatureSchema = z + .string() + .refine(isValidSolanaSignature, { + message: "must be a base58 Solana signature (64-byte ed25519)", + }) + .transform((s) => SolanaSignature(s.trim())); diff --git a/packages/ows-types/src/primitives/SolanaTransactionHash.ts b/packages/ows-types/src/primitives/SolanaTransactionHash.ts new file mode 100644 index 0000000..0382625 --- /dev/null +++ b/packages/ows-types/src/primitives/SolanaTransactionHash.ts @@ -0,0 +1,29 @@ +import { base58 } from "@scure/base"; +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * A base58-encoded Solana transaction id (explorer tx hash). + * On Solana this is the ed25519 signature of the transaction — same wire + * shape as a message {@link SolanaSignature}, branded so call sites do not + * mix message signatures with submitted transaction ids. + */ +export type SolanaTransactionHash = Brand; +export const SolanaTransactionHash = make(); + +function isValidSolanaTxHash(input: string): boolean { + const trimmed = input.trim(); + try { + return base58.decode(trimmed).length === 64; + } catch { + return false; + } +} + +/** Zod schema: base58 Solana transaction id decoding to 64 bytes. */ +export const SolanaTransactionHashSchema = z + .string() + .refine(isValidSolanaTxHash, { + message: "must be a base58 Solana transaction hash (64-byte ed25519)", + }) + .transform((s) => SolanaTransactionHash(s.trim())); diff --git a/packages/ows-types/src/primitives/TransactionHash.ts b/packages/ows-types/src/primitives/TransactionHash.ts new file mode 100644 index 0000000..e3b26d6 --- /dev/null +++ b/packages/ows-types/src/primitives/TransactionHash.ts @@ -0,0 +1,30 @@ +import { EVMTransactionHashSchema } from "./EVMTransactionHash.js"; +import type { EVMTransactionHash } from "./EVMTransactionHash.js"; +import { SolanaTransactionHashSchema } from "./SolanaTransactionHash.js"; +import type { SolanaTransactionHash } from "./SolanaTransactionHash.js"; + +/** + * Cross-chain transaction id for APIs that can surface either an EVM hex + * hash or a Solana base58 transaction id (e.g. CCTP burn / Iris forward). + */ +export type TransactionHash = EVMTransactionHash | SolanaTransactionHash; + +/** + * Brand a raw explorer / Iris tx id as EVM or Solana. Returns null when + * neither shape matches. + */ +export function parseTransactionHash(raw: string): TransactionHash | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const evm = EVMTransactionHashSchema.safeParse(trimmed); + if (evm.success) { + return evm.data; + } + const solana = SolanaTransactionHashSchema.safeParse(trimmed); + if (solana.success) { + return solana.data; + } + return null; +} diff --git a/packages/ows-types/src/primitives/UnixTimestamp.ts b/packages/ows-types/src/primitives/UnixTimestamp.ts new file mode 100644 index 0000000..bf3261d --- /dev/null +++ b/packages/ows-types/src/primitives/UnixTimestamp.ts @@ -0,0 +1,17 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Unix time in **seconds** (not JavaScript milliseconds from `Date.now()`). + * + * Example: `1719792000` ≈ `2024-07-01T00:00:00Z` + */ +export type UnixTimestamp = Brand; +export const UnixTimestamp = make(); + +/** Zod schema: Unix timestamp in seconds (non-negative integer). */ +export const UnixTimestampSchema = z + .number() + .int() + .nonnegative() + .transform((n) => UnixTimestamp(n)); diff --git a/packages/ows-types/src/primitives/UriString.ts b/packages/ows-types/src/primitives/UriString.ts new file mode 100644 index 0000000..734319f --- /dev/null +++ b/packages/ows-types/src/primitives/UriString.ts @@ -0,0 +1,32 @@ +import { type Brand, make } from "ts-brand"; +import { z } from "zod"; + +/** + * Full URI including scheme and typically a path (e.g. `https://example.com/foo`, + * `openid-credential-offer:…`). For hostname-only values, use {@link DomainString}. + * + * Example: `"https://example.com/callback"` + */ +export type UriString = Brand; +export const UriString = make(); + +function isUriLike(s: string): boolean { + if (!s.trim()) { + return false; + } + try { + // Absolute http(s) / known schemes + // eslint-disable-next-line no-new + new URL(s); + return true; + } catch { + // Custom schemes used by OID4 mocks (e.g. openid-credential-offer:…) + return /^[a-z][a-z0-9+.-]*:/i.test(s); + } +} + +/** Zod schema: absolute URL or custom-scheme URI. */ +export const UriStringSchema = z + .string() + .refine(isUriLike, { message: "must be a valid URI" }) + .transform((s) => UriString(s)); diff --git a/packages/ows-types/src/primitives/index.ts b/packages/ows-types/src/primitives/index.ts new file mode 100644 index 0000000..19dd826 --- /dev/null +++ b/packages/ows-types/src/primitives/index.ts @@ -0,0 +1,47 @@ +export * from "./AES256CipherText.js"; +export * from "./AES256CipherTextEnvelope.js"; +export * from "./AnalyticsEventId.js"; +export * from "./Base64String.js"; +export * from "./Base64UrlEncodedString.js"; +export * from "./BitcoinSegwitAccountAddress.js"; +export * from "./BitcoinSignatureHex.js"; +export * from "./BitcoinTransactionHash.js"; +export * from "./BitcoinChainId.js"; +export * from "./OWSChainId.js"; +export * from "./SolanaChainId.js"; +export * from "./CredentialClaimName.js"; +export * from "./CredentialConfigurationId.js"; +export * from "./CredentialFormatId.js"; +export * from "./CredentialId.js"; +export * from "./CredentialIssuer.js"; +export * from "./CredentialOfferUri.js"; +export * from "./CredentialScope.js"; +export * from "./CredentialTypeName.js"; +export * from "./DisplayRequestId.js"; +export * from "./DomainString.js"; +export * from "./ED25519PublicKey.js"; +export * from "./ISO8601DateTime.js"; +export * from "./JSONString.js"; +export * from "./PresentationRequestUri.js"; +export * from "./UriString.js"; +export * from "./UnixTimestamp.js"; +export * from "./EVMAccountAddress.js"; +export * from "./EVMContractAddress.js"; +export * from "./EVMChainId.js"; +export * from "./EVMSignatureHex.js"; +export * from "./EVMTransactionHash.js"; +export * from "./HexString.js"; +export * from "./JWKThumbprint.js"; +export * from "./COSEPublicKey.js"; +export * from "./SPKIPublicKey.js"; +export * from "./RPCCallId.js"; +export * from "./RelayerTransactionId.js"; +export * from "./SdJwtVcPresentationString.js"; +export * from "./SECP256K1PublicKey.js"; +export * from "./SolanaAccountAddress.js"; +export * from "./SolanaProgramAddress.js"; +export * from "./SolanaMintAddress.js"; +export * from "./SolanaATAAddress.js"; +export * from "./SolanaSignature.js"; +export * from "./SolanaTransactionHash.js"; +export * from "./TransactionHash.js"; diff --git a/packages/ows-types/src/protocol/analytics.ts b/packages/ows-types/src/protocol/analytics.ts new file mode 100644 index 0000000..2efe81d --- /dev/null +++ b/packages/ows-types/src/protocol/analytics.ts @@ -0,0 +1,106 @@ +import { AnalyticsEventId } from "../primitives/AnalyticsEventId.js"; +import { DomainString } from "../primitives/DomainString.js"; +import { UnixTimestamp } from "../primitives/UnixTimestamp.js"; + +/** Child → host: branding iframe publishes a product analytics event. */ +export const OWS_ANALYTICS_EVENT = "ows:analytics" as const; + +/** + * Required Branding→Host analytics base. Branding may attach additional + * properties; OWS does not type them. Event catalogs are branding-owned. + */ +export interface IOWSAnalyticsEvent { + eventId: AnalyticsEventId; + timestamp: UnixTimestamp; + hostDomain: DomainString; + name: string; + [key: string]: unknown; +} + +export type OWSAnalyticsEventOptions = { + eventId?: AnalyticsEventId; + timestamp?: UnixTimestamp; +}; + +/** + * Abstract base analytics event. Branding layers must extend this and call + * `super` so `eventId` / `timestamp` / branded `hostDomain` are filled. + * + * @example + * ```ts + * class AccountCreatedEvent extends OWSAnalyticsEvent { + * constructor( + * hostDomain: DomainString, + * public readonly accountAddress: EVMAccountAddress, + * ) { + * super("AccountCreated", hostDomain); + * } + * } + * ``` + */ +export abstract class OWSAnalyticsEvent implements IOWSAnalyticsEvent { + readonly eventId: AnalyticsEventId; + readonly timestamp: UnixTimestamp; + [key: string]: unknown; + + constructor( + public readonly name: string, + public readonly hostDomain: DomainString, + options?: OWSAnalyticsEventOptions, + ) { + if (name.length === 0) { + throw new Error("OWSAnalyticsEvent requires a non-empty name"); + } + if (hostDomain.length === 0) { + throw new Error("OWSAnalyticsEvent requires a non-empty hostDomain"); + } + + this.eventId = options?.eventId ?? AnalyticsEventId(crypto.randomUUID()); + this.timestamp = + options?.timestamp ?? UnixTimestamp(Math.floor(Date.now() / 1000)); + } +} + +/** + * Parse an `ows:analytics` Postmate payload. + * Validates and brands the four required base fields; preserves all other + * enumerable properties so hosts receive the full rich event. + */ +export function deserializeAnalyticsEvent(data: unknown): IOWSAnalyticsEvent { + const parsed = parseJson(data); + + if (typeof parsed.eventId !== "string" || parsed.eventId.length === 0) { + throw new Error("Invalid analytics event: eventId"); + } + if (typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp)) { + throw new Error("Invalid analytics event: timestamp"); + } + if (typeof parsed.hostDomain !== "string" || parsed.hostDomain.length === 0) { + throw new Error("Invalid analytics event: hostDomain"); + } + if (typeof parsed.name !== "string" || parsed.name.length === 0) { + throw new Error("Invalid analytics event: name"); + } + + return { + ...parsed, + eventId: AnalyticsEventId(parsed.eventId), + timestamp: UnixTimestamp(parsed.timestamp), + hostDomain: DomainString(parsed.hostDomain), + name: parsed.name, + }; +} + +function parseJson(data: unknown): Record { + if (typeof data === "string") { + const parsed: unknown = JSON.parse(data); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid analytics JSON payload"); + } + return parsed as Record; + } + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid analytics payload"); + } + return data as Record; +} diff --git a/packages/ows-types/src/protocol/bitcoin.ts b/packages/ows-types/src/protocol/bitcoin.ts new file mode 100644 index 0000000..bfb5f16 --- /dev/null +++ b/packages/ows-types/src/protocol/bitcoin.ts @@ -0,0 +1,36 @@ +import type { BitcoinChainId } from "../primitives/BitcoinChainId.js"; +import type { BitcoinSegwitAccountAddress } from "../primitives/BitcoinSegwitAccountAddress.js"; + +/** Namespaced Postmate wire keys for Bitcoin methods. */ +export const BITCOIN_WIRE_METHODS = { + getAccountAddresses: "bitcoin.getAccountAddresses", +} as const; + +export type BitcoinWireMethod = + (typeof BITCOIN_WIRE_METHODS)[keyof typeof BITCOIN_WIRE_METHODS]; + +/** + * An address descriptor conforming to the Reown / BIP-122 static-wallet pattern. + */ +export interface IBitcoinAccountAddressEntry { + address: BitcoinSegwitAccountAddress; + /** Compressed public key in hex format without 0x prefix. */ + publicKey?: string; + /** Derivation path (omitted for static non-HD wallets). */ + path?: string; + /** Intention of the address (e.g. "payment" or "ordinal"). */ + intention?: "payment" | "ordinal" | string; +} + +export interface IBitcoinGetAccountAddressesParams { + account?: string; + intentions?: string[]; + chainId?: BitcoinChainId; +} + +/** Shared contract for proxy.bitcoin (host) and wallet.bitcoin (branding). */ +export interface IOpenWalletBitcoinProvider { + getAccountAddresses( + params?: IBitcoinGetAccountAddressesParams, + ): Promise; +} diff --git a/packages/ows-types/src/protocol/credentials.ts b/packages/ows-types/src/protocol/credentials.ts new file mode 100644 index 0000000..c5d0bbd --- /dev/null +++ b/packages/ows-types/src/protocol/credentials.ts @@ -0,0 +1,26 @@ +import type { CredentialId } from "../primitives/CredentialId.js"; +import type { CredentialOfferInput, CredentialReceipt } from "../credentials/offer.js"; +import type { CredentialFilter, CredentialSummary } from "../credentials/filter.js"; +import type { + PresentationRequestInput, + PresentationResult, +} from "../credentials/presentation.js"; + +/** Namespaced Postmate wire keys — not generic RPC method names. */ +export const CREDENTIAL_WIRE_METHODS = { + acceptOffer: "credentials.acceptOffer", + present: "credentials.present", + list: "credentials.list", + delete: "credentials.delete", +} as const; + +export type CredentialWireMethod = + (typeof CREDENTIAL_WIRE_METHODS)[keyof typeof CREDENTIAL_WIRE_METHODS]; + +/** Shared contract for proxy.credentials (host) and wallet.credentials (branding). */ +export interface OpenWalletCredentialProvider { + acceptOffer(input: CredentialOfferInput): Promise; + present(input: PresentationRequestInput): Promise; + list(filter?: CredentialFilter): Promise; + delete(input: { credentialId: CredentialId }): Promise; +} diff --git a/packages/ows-types/src/protocol/display.ts b/packages/ows-types/src/protocol/display.ts new file mode 100644 index 0000000..d0dedfb --- /dev/null +++ b/packages/ows-types/src/protocol/display.ts @@ -0,0 +1,106 @@ +import { DisplayRequestId } from "../primitives/DisplayRequestId.js"; + +/** Child → host: branding iframe requests visible/focusable layout. */ +export const OWS_REQUEST_DISPLAY_EVENT = "ows:requestDisplay" as const; + +/** Child → host: branding iframe no longer needs visible layout. */ +export const OWS_RELEASE_DISPLAY_EVENT = "ows:releaseDisplay" as const; + +/** Child → host: branding iframe requests the host hide the wallet panel. */ +export const OWS_REQUEST_HIDE_EVENT = "ows:requestHide" as const; + +/** + * Host → child Postmate model method carrying display-ready notification. + * Not an EIP-1193 or custom RPC method. + */ +export const OWS_DISPLAY_READY_MODEL_METHOD = "__owsDisplayReady" as const; + +/** Host → child: host confirms the wallet panel is hidden. */ +export const OWS_HIDE_READY_MODEL_METHOD = "__owsHideReady" as const; + +export const DEFAULT_DISPLAY_TIMEOUT_MS = 30_000; + +/** + * Branding requests the host show the wallet panel at the host-configured size. + * Panel dimensions are owned by {@link OWSProxy} (`walletSizeX` / `walletSizeY`); + * branding must scale to the iframe. + */ +export type RequestDisplayParams = Record; + +export type RequestDisplayEnvelope = { + displayId: DisplayRequestId; +}; + +export type DisplayReadyPayload = { + displayId: DisplayRequestId; +}; + +export type ReleaseDisplayEnvelope = { + displayId: DisplayRequestId; +}; + +export type RequestHideEnvelope = { + displayId?: DisplayRequestId; +}; + +export type HideReadyPayload = { + displayId?: DisplayRequestId; +}; + +export function deserializeRequestDisplay(data: unknown): RequestDisplayEnvelope { + const parsed = parseJson(data); + if (typeof parsed.displayId !== "string") { + throw new Error("Invalid request display envelope"); + } + return { + displayId: DisplayRequestId(parsed.displayId), + }; +} + +export function deserializeDisplayReady(data: unknown): DisplayReadyPayload { + const parsed = parseJson(data); + if (typeof parsed.displayId !== "string") { + throw new Error("Invalid display ready payload"); + } + return { displayId: DisplayRequestId(parsed.displayId) }; +} + +export function deserializeReleaseDisplay(data: unknown): ReleaseDisplayEnvelope { + const parsed = parseJson(data); + if (typeof parsed.displayId !== "string") { + throw new Error("Invalid release display envelope"); + } + return { displayId: DisplayRequestId(parsed.displayId) }; +} + +export function deserializeRequestHide(data: unknown): RequestHideEnvelope { + const parsed = parseJson(data); + const envelope: RequestHideEnvelope = {}; + if (typeof parsed.displayId === "string") { + envelope.displayId = DisplayRequestId(parsed.displayId); + } + return envelope; +} + +export function deserializeHideReady(data: unknown): HideReadyPayload { + const parsed = parseJson(data); + const payload: HideReadyPayload = {}; + if (typeof parsed.displayId === "string") { + payload.displayId = DisplayRequestId(parsed.displayId); + } + return payload; +} + +function parseJson(data: unknown): Record { + if (typeof data === "string") { + const parsed: unknown = JSON.parse(data); + if (!parsed || typeof parsed !== "object") { + throw new Error("Invalid display JSON payload"); + } + return parsed as Record; + } + if (!data || typeof data !== "object") { + throw new Error("Invalid display payload"); + } + return data as Record; +} diff --git a/packages/ows-types/src/protocol/eip1193-events.ts b/packages/ows-types/src/protocol/eip1193-events.ts new file mode 100644 index 0000000..f6032ca --- /dev/null +++ b/packages/ows-types/src/protocol/eip1193-events.ts @@ -0,0 +1,47 @@ +/** Child → host: branding pushes an EIP-1193 provider notification. */ +export const OWS_EIP1193_EVENT = "ows:eip1193" as const; + +/** + * Branding→Host EIP-1193 notification payload. + * `event` is the EIP-1193 event name (`chainChanged`, `accountsChanged`, …); + * `params` are the listener arguments (spread when delivered on the host). + */ +export interface IOWSEip1193EventNotification { + event: string; + params: unknown[]; +} + +/** + * Parse an `ows:eip1193` Postmate payload. + */ +export function deserializeEip1193EventNotification( + data: unknown, +): IOWSEip1193EventNotification { + const parsed = parseJson(data); + + if (typeof parsed.event !== "string" || parsed.event.length === 0) { + throw new Error("Invalid EIP-1193 event notification: event"); + } + if (!Array.isArray(parsed.params)) { + throw new Error("Invalid EIP-1193 event notification: params"); + } + + return { + event: parsed.event, + params: parsed.params, + }; +} + +function parseJson(data: unknown): Record { + if (typeof data === "string") { + const parsed: unknown = JSON.parse(data); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid EIP-1193 event JSON payload"); + } + return parsed as Record; + } + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid EIP-1193 event payload"); + } + return data as Record; +} diff --git a/packages/ows-types/src/protocol/ows-signer.ts b/packages/ows-types/src/protocol/ows-signer.ts new file mode 100644 index 0000000..6ec2491 --- /dev/null +++ b/packages/ows-types/src/protocol/ows-signer.ts @@ -0,0 +1,95 @@ +import type { AES256CipherTextEnvelope } from "../primitives/AES256CipherTextEnvelope.js"; +import type { BitcoinChainId } from "../primitives/BitcoinChainId.js"; +import type { BitcoinSegwitAccountAddress } from "../primitives/BitcoinSegwitAccountAddress.js"; +import type { CredentialId } from "../primitives/CredentialId.js"; +import type { EVMAccountAddress } from "../primitives/EVMAccountAddress.js"; +import type { HexString } from "../primitives/HexString.js"; +import type { SolanaAccountAddress } from "../primitives/SolanaAccountAddress.js"; +import type { + CeremonyUiParams, + CreateCredentialOptions, + CredentialCreatedData, + DigestSignedData, + ExecuteBatchParams, + ExecuteBatchResult, + GetPublicKeyParams, + PublicKeyData, + RecoveryCeremonyOptions, + RecoveryDataCreatedData, + SignScheme, + VersionData, + WebAuthnAssertionFields, +} from "./signer.js"; + +/** + * Branding Layer ↔ Signing Layer contract. + * + * Implemented by `OWSSigner` in `@1shotapi/ows-signer-utils`. Prefer this + * interface in add-ons (e.g. `@1shotapi/ows-oid4`) over depending on the + * concrete class or inventing duck-typed clones. + */ +export interface IOWSSigner { + getCredentialId(): CredentialId | undefined; + getLastPublicKeyData(): PublicKeyData | undefined; + getCachedAddress(): EVMAccountAddress | undefined; + setCachedAddress(address: EVMAccountAddress): void; + getCachedSolanaAddress(): SolanaAccountAddress | undefined; + setCachedSolanaAddress(address: SolanaAccountAddress): void; + getCachedBitcoinSegwitAddress( + chainId?: BitcoinChainId, + ): BitcoinSegwitAccountAddress | undefined; + setCachedBitcoinSegwitAddress( + chainId: BitcoinChainId, + address: BitcoinSegwitAccountAddress, + ): void; + + getVersion(): Promise; + createCredential( + name: string, + options?: CreateCredentialOptions, + ): Promise; + /** + * Sign digests under one passkey ceremony. + * Default scheme per item (when omitted on the call site) is + * `secp256k1-ecdsa-recoverable`. + */ + signDigest( + digests: Array<{ + digestData: HexString | `0x${string}`; + scheme?: SignScheme; + }>, + options?: CeremonyUiParams & { credentialId?: CredentialId }, + ): Promise; + executeBatch(params: ExecuteBatchParams): Promise; + getPublicKey( + params: GetPublicKeyParams & { challenge: `0x${string}` }, + ): Promise; + getPublicKey( + params?: GetPublicKeyParams, + ): Promise; + createRecoveryData( + passwordText: string, + buttonText: string, + minPasswordLength: number, + options?: RecoveryCeremonyOptions, + ): Promise; + recoverKey( + aes256EncryptedPrivateKey: string, + passwordText: string, + buttonText: string, + options?: RecoveryCeremonyOptions, + ): Promise; + revealPrivateKey(options?: RecoveryCeremonyOptions): Promise; + /** Paste a hex private key into the Signing Layer; starts a tab recovery session. */ + importPrivateKey(): Promise; + clearRecoverySession(): Promise; + encryptAES256( + plaintexts: string[], + options?: CeremonyUiParams & { credentialId?: CredentialId }, + ): Promise; + decryptAES256( + ciphertexts: AES256CipherTextEnvelope[], + options?: CeremonyUiParams & { credentialId?: CredentialId }, + ): Promise; + destroy(): void; +} diff --git a/packages/ows-types/src/protocol/serde.ts b/packages/ows-types/src/protocol/serde.ts new file mode 100644 index 0000000..1e208c7 --- /dev/null +++ b/packages/ows-types/src/protocol/serde.ts @@ -0,0 +1,42 @@ +import type { RpcRequestEnvelope, RpcResponseEnvelope } from "./wallet.js"; + +export function serializeRpc(value: T): string { + return JSON.stringify(value); +} + +export function deserializeRpcRequest(data: unknown): RpcRequestEnvelope { + const parsed = parseJson(data); + if ( + typeof parsed.callId !== "number" || + typeof parsed.method !== "string" || + !("params" in parsed) + ) { + throw new Error("Invalid RPC request envelope"); + } + return parsed as RpcRequestEnvelope; +} + +export function deserializeRpcResponse(data: unknown): RpcResponseEnvelope { + const parsed = parseJson(data); + if ( + typeof parsed.callId !== "number" || + typeof parsed.success !== "boolean" + ) { + throw new Error("Invalid RPC response envelope"); + } + return parsed as RpcResponseEnvelope; +} + +function parseJson(data: unknown): Record { + if (typeof data === "string") { + const parsed: unknown = JSON.parse(data); + if (!parsed || typeof parsed !== "object") { + throw new Error("Invalid RPC JSON payload"); + } + return parsed as Record; + } + if (!data || typeof data !== "object") { + throw new Error("Invalid RPC payload"); + } + return data as Record; +} diff --git a/packages/ows-types/src/protocol/signer.ts b/packages/ows-types/src/protocol/signer.ts new file mode 100644 index 0000000..676c8e3 --- /dev/null +++ b/packages/ows-types/src/protocol/signer.ts @@ -0,0 +1,241 @@ +import type { COSEPublicKey } from "../primitives/COSEPublicKey.js"; +import { CredentialId } from "../primitives/CredentialId.js"; +import type { ED25519PublicKey } from "../primitives/ED25519PublicKey.js"; +import type { HexString } from "../primitives/HexString.js"; +import type { JSONString } from "../primitives/JSONString.js"; +import type { SECP256K1PublicKey } from "../primitives/SECP256K1PublicKey.js"; + +export const API_VERSION = 1 as const; + +export type SignScheme = + | "secp256k1-ecdsa" + | "secp256k1-ecdsa-recoverable" + | "secp256k1-bip340" + | "ed25519"; + +export type SignerMethod = + | "getVersion" + | "createCredential" + | "signDigest" + | "revealPrivateKey" + | "importPrivateKey" + | "createRecoveryData" + | "recoverKey" + | "getPublicKey" + | "clearRecoverySession" + | "encryptAES256" + | "decryptAES256" + | "executeBatch"; + +export type SignerEvent = + | "Version" + | "KeyDerived" + | "CredentialCreated" + | "DigestSigned" + | "RecoveryDataCreated" + | "RecoverySessionStarted" + | "RecoverySessionCleared" + | "PrivateKeyRevealed" + | "PublicKey" + | "ChallengeSigned" + | "AES256Encrypted" + | "AES256Decrypted" + | "BatchExecuted" + | "SignDenied" + | "NotAllowed" + | "InvalidRequest"; + +/** + * Optional copy for the Signing Layer Confirm/Cancel panel shown before + * WebAuthn. Defaults are applied by the signer when omitted. + */ +export type CeremonyUiParams = { + explanationHeader?: string; + explanationText?: string; + confirmButtonText?: string; + denyButtonText?: string; +}; + +export type SignerRequest = { + v: typeof API_VERSION; + kind: "request"; + method: SignerMethod; + correlationId?: string; + params?: Record; +}; + +export type SignerEventMessage = { + v: typeof API_VERSION; + kind: "event"; + event: SignerEvent; + correlationId?: string; + data: Record; +}; + +export type VersionData = { + apiVersion: number; + signerVersion: string; + recoverySessionActive: boolean; +}; + +export type KeyDerivedData = { + secp256k1PublicKey: SECP256K1PublicKey; + ed25519PublicKey: ED25519PublicKey; +}; + +export type CredentialCreatedData = { + credentialId: CredentialId; + /** Authenticator COSE public key; only available on create (attestation). */ + cosePublicKey: COSEPublicKey | null; + /** + * PRF-derived wallet secp256k1 public key. + * Absent when `deferKeyDerivation` was set on create (registration only). + */ + secp256k1PublicKey?: SECP256K1PublicKey; +}; + +export type DigestSignItem = { + digestData: `0x${string}`; + scheme: SignScheme; +}; + +export type DigestSignedData = { + digest: `0x${string}`; + scheme: SignScheme; + signature: `0x${string}`; + credentialId: CredentialId | null; +}; + +/** Terminal `DigestSigned` event payload (batch). */ +export type DigestSignedResult = { + results: DigestSignedData[]; +}; + +/** + * Batch sign digests under one passkey ceremony. + * Arrays amortize a single unlock across multiple digests. + */ +export type SignDigestParams = CeremonyUiParams & { + digests: DigestSignItem[]; + credentialId?: CredentialId; +}; + +/** + * Mixed ceremony: sign digests, AES encrypt/decrypt, public key, and/or + * WebAuthn challenge auth under a single passkey assertion. + */ +export type ExecuteBatchParams = CeremonyUiParams & { + credentialId?: CredentialId; + /** WebAuthn assertion challenge (e.g. relayer auth). Forces a real assertion. */ + challenge?: `0x${string}`; + digests?: DigestSignItem[]; + plaintexts?: string[]; + ciphertexts?: string[]; + /** Include PublicKey payload in the result (addresses via KeyDerived). */ + includePublicKey?: boolean; +}; + +export type RecoveryDataCreatedData = { + encryptedPrivateKey: string; +}; + +export type RecoverySessionStartedData = { + recoverySessionActive: true; +}; + +export type RecoverySessionClearedData = { + rebound?: boolean; +}; + +export type PublicKeyData = { + /** + * Authenticator COSE public key when available (create/attestation only). + * Assertions do not include credentialPublicKey — typically `null` here. + */ + cosePublicKey: COSEPublicKey | null; + secp256k1PublicKey: SECP256K1PublicKey; + ed25519PublicKey: ED25519PublicKey; + credentialId?: CredentialId; +}; + +/** + * WebAuthn assertion fields after OWSSigner decodes the Signing Layer wire + * payload (iframe still sends base64url). Branding re-encodes for protocols + * that need base64url (e.g. SimpleWebAuthn / relayer). + * + * - `authenticatorData` / `signature` — raw bytes as {@link HexString} + * - `clientDataJSON` — UTF-8 JSON text as {@link JSONString} (parseable via + * `JSON.parse` with no further decoding) + */ +export type WebAuthnAssertionFields = { + authenticatorData: HexString; + clientDataJSON: JSONString; + signature: HexString; + credentialId: CredentialId; +}; + +/** Terminal `BatchExecuted` event payload. */ +export type ExecuteBatchResult = { + results?: DigestSignedData[]; + ciphertexts?: string[]; + plaintexts?: string[]; + publicKey?: PublicKeyData; + /** Present when `challenge` was supplied to the ceremony. */ + assertion?: WebAuthnAssertionFields; + credentialId?: CredentialId | null; +}; + +export type ChallengeSignedData = { + challenge: `0x${string}`; + assertion: WebAuthnAssertionFields; +}; + +export type CreateCredentialOptions = CeremonyUiParams & { + rpName?: string; + userDisplayName?: string; + userId?: string; + /** + * When true, only run WebAuthn registration — skip the PRF follow-up get and + * omit `secp256k1PublicKey` from `CredentialCreated`. Use when a different + * signer session (e.g. opener after Safari `/create`) will unlock later. + */ + deferKeyDerivation?: boolean; +}; + +export type GetPublicKeyParams = CeremonyUiParams & { + credentialId?: CredentialId; + challenge?: `0x${string}`; + /** When true, use discoverable credentials (omit allowCredentials). */ + discoverable?: boolean; +}; + +/** + * Batch encrypt plaintexts with PRF-derived AES-256-GCM + * (HKDF from the wallet secp256k1 scalar — same material as `signDigest`). + * Arrays amortize a single passkey ceremony across multiple plaintexts. + */ +export type EncryptAES256Params = CeremonyUiParams & { + plaintexts: string[]; + credentialId?: CredentialId; +}; + +export type EncryptAES256Result = { + ciphertexts: string[]; +}; + +/** + * Batch decrypt AES-256-GCM envelopes (`ows-aes1:…`). + */ +export type DecryptAES256Params = CeremonyUiParams & { + ciphertexts: string[]; + credentialId?: CredentialId; +}; + +/** Options for recovery / reveal RPCs that may trigger WebAuthn. */ +export type RecoveryCeremonyOptions = CeremonyUiParams & { + credentialId?: CredentialId; +}; + +export type DecryptAES256Result = { + plaintexts: string[]; +}; diff --git a/packages/ows-types/src/protocol/solana.ts b/packages/ows-types/src/protocol/solana.ts new file mode 100644 index 0000000..28713ec --- /dev/null +++ b/packages/ows-types/src/protocol/solana.ts @@ -0,0 +1,119 @@ +import type { Base64String } from "../primitives/Base64String.js"; +import type { DomainString } from "../primitives/DomainString.js"; +import type { ISO8601DateTime } from "../primitives/ISO8601DateTime.js"; +import type { SolanaAccountAddress } from "../primitives/SolanaAccountAddress.js"; +import type { SolanaChainId } from "../primitives/SolanaChainId.js"; +import type { SolanaSignature } from "../primitives/SolanaSignature.js"; +import type { SolanaTransactionHash } from "../primitives/SolanaTransactionHash.js"; +import type { UriString } from "../primitives/UriString.js"; + +/** + * Namespaced Postmate wire keys for Solana methods (Host ↔ Branding). + * + * Anticipated Wallet Standard / product mapping: + * - connect / account → {@link SOLANA_WIRE_METHODS.getAccountAddress} + * - `solana:signMessage` → {@link SOLANA_WIRE_METHODS.signMessage} + * - `solana:signIn` (SIWS) → {@link SOLANA_WIRE_METHODS.signIn} (Branding formats; signs via message) + * - `solana:signTransaction` → {@link SOLANA_WIRE_METHODS.signTransaction} + * - `solana:signAndSendTransaction` → {@link SOLANA_WIRE_METHODS.signAndSendTransaction} + */ +export const SOLANA_WIRE_METHODS = { + getAccountAddress: "solana.getAccountAddress", + signMessage: "solana.signMessage", + signTransaction: "solana.signTransaction", + signAndSendTransaction: "solana.signAndSendTransaction", + signIn: "solana.signIn", +} as const; + +export type SolanaWireMethod = + (typeof SOLANA_WIRE_METHODS)[keyof typeof SOLANA_WIRE_METHODS]; + +export interface ISolanaGetAccountAddressParams { + chainId?: SolanaChainId; +} + +/** Compiled transaction message bytes as base64 (Host/Branding wire). */ +export interface ISolanaSignMessageParams { + /** One or more UTF-8 / arbitrary message payloads as base64. */ + messages: Base64String[]; +} + +export interface ISolanaSignMessageResult { + signatures: SolanaSignature[]; +} + +export interface ISolanaSignTransactionParams { + /** + * One or more compiled Solana transaction **message** payloads as base64 + * (Kit `messageBytes` / Wallet Standard serialized message — not a full + * signed wire transaction). + */ + transactions: Base64String[]; +} + +export interface ISolanaSignTransactionResult { + signatures: SolanaSignature[]; +} + +export interface ISolanaSignAndSendTransactionParams { + /** Compiled transaction message bytes as base64. */ + transaction: Base64String; + chainId?: SolanaChainId; +} + +export interface ISolanaSignAndSendTransactionResult { + signature: SolanaTransactionHash; +} + +/** + * SIWS input fields (Wallet Standard `solana:signIn`). Branding constructs the + * message; structure refined in Phase 5. + * + * Timestamps stay {@link ISO8601DateTime} (SIWS wire is ISO strings), not + * {@link UnixTimestamp}. + */ +export interface ISolanaSignInParams { + domain?: DomainString; + address?: SolanaAccountAddress; + /** Free-form purpose text (no newlines in SIWS). */ + statement?: string; + uri?: UriString; + /** SIWS version string (typically `"1"`). */ + version?: string; + chainId?: SolanaChainId; + /** Replay nonce (≥8 alphanumeric in SIWS). */ + nonce?: string; + issuedAt?: ISO8601DateTime; + expirationTime?: ISO8601DateTime; + notBefore?: ISO8601DateTime; + requestId?: string; + resources?: UriString[]; +} + +export interface ISolanaSignInResult { + account: SolanaAccountAddress; + /** UTF-8 message bytes that were signed, as standard base64. */ + signedMessage: Base64String; + signature: SolanaSignature; +} + +/** Shared contract for `proxy.solana` (host) and `wallet.solana` (branding). */ +export interface IOpenWalletSolanaProvider { + getAccountAddress( + params?: ISolanaGetAccountAddressParams, + ): Promise; + + signMessage( + params: ISolanaSignMessageParams, + ): Promise; + + signTransaction( + params: ISolanaSignTransactionParams, + ): Promise; + + signAndSendTransaction( + params: ISolanaSignAndSendTransactionParams, + ): Promise; + + signIn(params?: ISolanaSignInParams): Promise; +} diff --git a/packages/ows-types/src/protocol/wallet.ts b/packages/ows-types/src/protocol/wallet.ts new file mode 100644 index 0000000..483d0a1 --- /dev/null +++ b/packages/ows-types/src/protocol/wallet.ts @@ -0,0 +1,32 @@ +import { RPCCallId } from "../primitives/index.js"; + +/** Postmate emit event name for async RPC callbacks (child → host). */ +export const OWS_RPC_CALLBACK_EVENT = "ows:rpcCallback" as const; + +/** JSON-RPC method not found */ +export const RPC_ERROR_UNIMPLEMENTED = -32_601; + +/** JSON-RPC invalid params */ +export const RPC_ERROR_INVALID_PARAMS = -32_602; + +/** EIP-1193 user rejected request */ +export const RPC_ERROR_USER_REJECTED = 4001; + +export type RpcErrorPayload = { + code: number; + message: string; + data?: unknown; +}; + +export type RpcRequestEnvelope = { + callId: RPCCallId; + method: string; + params: unknown; +}; + +export type RpcResponseEnvelope = { + callId: RPCCallId; + success: boolean; + result?: unknown; + error?: RpcErrorPayload; +}; diff --git a/packages/ows-types/src/utils/ChainUtils.ts b/packages/ows-types/src/utils/ChainUtils.ts new file mode 100644 index 0000000..348ece6 --- /dev/null +++ b/packages/ows-types/src/utils/ChainUtils.ts @@ -0,0 +1,138 @@ +import { EChainTechnology } from "../enums/EChainTechnology.js"; +import { OwsInvalidParamsError } from "../errors/rpc.js"; +import { + BITCOIN_CAIP2, + BITCOIN_MAINNET_CHAIN_ID, + BITCOIN_TESTNET_CHAIN_ID, + type BitcoinChainId as BitcoinChainIdType, +} from "../primitives/BitcoinChainId.js"; +import { + EVMChainIdSchema, + type EVMChainId as EVMChainIdType, +} from "../primitives/EVMChainId.js"; +import type { OWSChainId } from "../primitives/OWSChainId.js"; +import { + SOLANA_CAIP2, + SOLANA_DEVNET_CHAIN_ID, + SOLANA_MAINNET_CHAIN_ID, + SOLANA_TESTNET_CHAIN_ID, + type SolanaChainId as SolanaChainIdType, +} from "../primitives/SolanaChainId.js"; + +const EVM_CHAIN_ID_RE = /^0x[0-9a-fA-F]+$/; + +/** + * Narrow / normalize {@link OWSChainId} values across EVM, Bitcoin, and Solana. + */ +export class ChainUtils { + static isBitcoinChainId(chainId: unknown): chainId is BitcoinChainIdType { + return ( + chainId === BITCOIN_MAINNET_CHAIN_ID || + chainId === BITCOIN_TESTNET_CHAIN_ID + ); + } + + /** Brand a known Bitcoin sentinel, or throw. */ + static asBitcoinChainId(chainId: unknown): BitcoinChainIdType { + if (ChainUtils.isBitcoinChainId(chainId)) { + return chainId; + } + throw new Error(`Invalid BitcoinChainId: ${String(chainId)}`); + } + + static isEVMChainId(chainId: unknown): chainId is EVMChainIdType { + return typeof chainId === "string" && EVM_CHAIN_ID_RE.test(chainId); + } + + /** + * Normalize an EIP-155 chain id into a branded {@link EVMChainId} + * (`0x` + lowercase hex, no leading zeros). + * + * Accepts hex strings (`0x01`, `0XAA`), decimal strings (`8453`), + * numbers, and bigints. Trims string input. Prefer this at validation / + * derivation boundaries so branded ids compare with `===`. + * + * @throws {OwsInvalidParamsError} when `value` is not a valid chain id + */ + static asEVMChainId(value: number | string | bigint): EVMChainIdType { + try { + if (typeof value === "number") { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + throw new OwsInvalidParamsError(`Invalid chainId: ${String(value)}`); + } + return EVMChainIdSchema.parse(`0x${BigInt(value).toString(16)}`); + } + if (typeof value === "bigint") { + if (value < 0n) { + throw new OwsInvalidParamsError(`Invalid chainId: ${String(value)}`); + } + return EVMChainIdSchema.parse(`0x${value.toString(16)}`); + } + const trimmed = value.trim(); + if (/^0x[0-9a-fA-F]+$/i.test(trimmed)) { + // Schema requires a lowercase `0x` prefix; hex body case is normalized by BigInt. + return EVMChainIdSchema.parse(`0x${trimmed.slice(2)}`); + } + if (/^[0-9]+$/.test(trimmed)) { + return EVMChainIdSchema.parse(`0x${BigInt(trimmed).toString(16)}`); + } + throw new OwsInvalidParamsError(`Invalid chainId: ${String(value)}`); + } catch (error) { + if (error instanceof OwsInvalidParamsError) throw error; + throw new OwsInvalidParamsError(`Invalid chainId: ${String(value)}`); + } + } + + static isSolanaChainId(chainId: unknown): chainId is SolanaChainIdType { + return ( + chainId === SOLANA_MAINNET_CHAIN_ID || + chainId === SOLANA_DEVNET_CHAIN_ID || + chainId === SOLANA_TESTNET_CHAIN_ID + ); + } + + /** Brand a known Solana CAIP-2 sentinel, or throw. */ + static asSolanaChainId(chainId: unknown): SolanaChainIdType { + if (ChainUtils.isSolanaChainId(chainId)) { + return chainId; + } + throw new Error(`Invalid SolanaChainId: ${String(chainId)}`); + } + + /** Map a branded chain id to its address / signing family. */ + static technologyFor(chainId: OWSChainId): EChainTechnology { + if (ChainUtils.isBitcoinChainId(chainId)) { + return EChainTechnology.Bitcoin; + } + if (ChainUtils.isSolanaChainId(chainId)) { + return EChainTechnology.Solana; + } + return EChainTechnology.Evm; + } + + static bitcoinChainIdToCaip2(chainId: BitcoinChainIdType): string { + return chainId === BITCOIN_MAINNET_CHAIN_ID + ? BITCOIN_CAIP2.MAINNET + : BITCOIN_CAIP2.TESTNET; + } + + static caip2ToBitcoinChainId( + caip2: string, + ): BitcoinChainIdType | undefined { + if (caip2 === BITCOIN_CAIP2.MAINNET) return BITCOIN_MAINNET_CHAIN_ID; + if (caip2 === BITCOIN_CAIP2.TESTNET) return BITCOIN_TESTNET_CHAIN_ID; + return undefined; + } + + /** Solana branded ids are already CAIP-2 / Wallet Standard strings. */ + static solanaChainIdToCaip2(chainId: SolanaChainIdType): string { + return chainId; + } + + static caip2ToSolanaChainId(caip2: string): SolanaChainIdType | undefined { + if (caip2 === SOLANA_CAIP2.MAINNET) return SOLANA_MAINNET_CHAIN_ID; + if (caip2 === SOLANA_CAIP2.DEVNET) return SOLANA_DEVNET_CHAIN_ID; + if (caip2 === SOLANA_CAIP2.TESTNET) return SOLANA_TESTNET_CHAIN_ID; + return undefined; + } +} diff --git a/packages/ows-types/src/utils/ConversionUtils.ts b/packages/ows-types/src/utils/ConversionUtils.ts new file mode 100644 index 0000000..70f88b3 --- /dev/null +++ b/packages/ows-types/src/utils/ConversionUtils.ts @@ -0,0 +1,164 @@ +import { getAddressEncoder } from "@solana/addresses"; + +import { Base64String } from "../primitives/Base64String.js"; +import { Base64UrlEncodedString } from "../primitives/Base64UrlEncodedString.js"; +import type { EVMAccountAddress } from "../primitives/EVMAccountAddress.js"; +import type { EVMContractAddress } from "../primitives/EVMContractAddress.js"; +import { HexString } from "../primitives/HexString.js"; +import type { SolanaAccountAddress } from "../primitives/SolanaAccountAddress.js"; +import type { SolanaATAAddress } from "../primitives/SolanaATAAddress.js"; +import type { SolanaProgramAddress } from "../primitives/SolanaProgramAddress.js"; + +import { SolanaAddressUtils } from "./SolanaAddressUtils.js"; + +/** EVM account or contract address (same 20-byte wire shape). */ +export type EVMAddressLike = EVMAccountAddress | EVMContractAddress; + +/** Solana 32-byte pubkey brands (account / program / ATA). */ +export type SolanaPubkeyLike = + | SolanaAccountAddress + | SolanaProgramAddress + | SolanaATAAddress; + +/** + * Shared branded encoding/decoding helpers (hex, UTF-8, base64, base64url). + * Prefer these over local copies outside `@1shotapi/ows-signer`. + */ +export class ConversionUtils { + /** Decode a `0x`-prefixed {@link HexString} to bytes (pads odd-length hex). */ + static hexToBytes(hex: HexString): Uint8Array { + let stripped = String(hex); + if (stripped.startsWith("0x") || stripped.startsWith("0X")) { + stripped = stripped.slice(2); + } + if (stripped.length % 2 !== 0) { + stripped = `0${stripped}`; + } + const bytes = new Uint8Array(stripped.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(stripped.slice(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + /** + * Narrow an unknown value to {@link Uint8Array}, or throw. + * @param label — included in the error message (e.g. field name). + */ + static asUint8Array(value: unknown, label = "value"): Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new Error(`${label} must be bytes`); + } + return value; + } + + /** Encode bytes as a `0x`-prefixed {@link HexString}. */ + static bytesToHex(bytes: Uint8Array): HexString { + return HexString( + `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`, + ); + } + + /** + * Left-pad a 20-byte EVM address to `bytes32` (32-byte hex). + * Accepts checksummed or lowercase `0x` addresses; output is lowercase. + * Does not perform EIP-55 checksum validation — pass a branded + * {@link EVMAccountAddress} / {@link EVMContractAddress} (or equivalent). + */ + static addressToBytes32Hex(address: EVMAddressLike | `0x${string}`): HexString { + const normalized = String(address).toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(normalized)) { + throw new Error( + "address must be a 20-byte 0x-prefixed hex string", + ); + } + return HexString(`0x${"0".repeat(24)}${normalized.slice(2)}`); + } + + /** + * Left-pad a 20-byte EVM address to a 32-byte Solana pubkey (CCTP + * `mintRecipient` when burning on Solana toward an EVM destination). + */ + static addressToSolanaPubkeyBytes( + address: EVMAddressLike, + ): Uint8Array { + return ConversionUtils.hexToBytes( + ConversionUtils.addressToBytes32Hex(address), + ); + } + + /** + * Encode a Solana base58 pubkey (account / program / ATA) as `bytes32` hex + * (CCTP `mintRecipient` when burning on EVM toward Solana). + */ + static solanaAccountToBytes32(address: SolanaPubkeyLike): HexString { + const bytes = new Uint8Array( + getAddressEncoder().encode(SolanaAddressUtils.toKitAddress(address)), + ); + if (bytes.length !== 32) { + throw new Error("Solana address must encode to 32 bytes"); + } + return ConversionUtils.bytesToHex(bytes); + } + + /** UTF-8 encode a string, then {@link bytesToHex}. */ + static utf8ToHex(value: string): HexString { + return ConversionUtils.bytesToHex(new TextEncoder().encode(value)); + } + + /** Encode bytes as standard base64 ({@link Base64String}). */ + static bytesToBase64(bytes: Uint8Array): Base64String { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return Base64String(btoa(binary)); + } + + /** Decode standard base64 ({@link Base64String}) to bytes. */ + static base64ToBytes(value: Base64String): Uint8Array { + const binary = atob(value); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; + } + + /** Encode bytes as unpadded base64url. */ + static bytesToBase64Url(bytes: Uint8Array): Base64UrlEncodedString { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return Base64UrlEncodedString( + btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), + ); + } + + /** Decode unpadded (or padded) base64url to bytes. */ + static base64UrlToBytes(value: Base64UrlEncodedString): Uint8Array { + const padded = value + "=".repeat((4 - (value.length % 4)) % 4); + const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + /** UTF-8 JSON → branded base64url (JWT header/payload segments). */ + static encodeJsonBase64Url(value: unknown): Base64UrlEncodedString { + return ConversionUtils.bytesToBase64Url( + new TextEncoder().encode(JSON.stringify(value)), + ); + } + + /** Decode a base64url JSON segment (unpadded JWT header/payload). */ + static decodeJsonBase64Url(segment: string): T { + const bytes = ConversionUtils.base64UrlToBytes( + Base64UrlEncodedString(segment), + ); + return JSON.parse(new TextDecoder().decode(bytes)) as T; + } +} diff --git a/packages/ows-types/src/utils/SolanaAddressUtils.ts b/packages/ows-types/src/utils/SolanaAddressUtils.ts new file mode 100644 index 0000000..b5b343a --- /dev/null +++ b/packages/ows-types/src/utils/SolanaAddressUtils.ts @@ -0,0 +1,43 @@ +import { address, type Address } from "@solana/addresses"; + +import type { SolanaAccountAddress } from "../primitives/SolanaAccountAddress.js"; +import { SolanaAccountAddress as makeSolanaAccountAddress } from "../primitives/SolanaAccountAddress.js"; +import type { SolanaATAAddress } from "../primitives/SolanaATAAddress.js"; +import { SolanaATAAddress as makeSolanaATAAddress } from "../primitives/SolanaATAAddress.js"; +import type { SolanaMintAddress } from "../primitives/SolanaMintAddress.js"; +import { SolanaMintAddress as makeSolanaMintAddress } from "../primitives/SolanaMintAddress.js"; +import type { SolanaProgramAddress } from "../primitives/SolanaProgramAddress.js"; +import { SolanaProgramAddress as makeSolanaProgramAddress } from "../primitives/SolanaProgramAddress.js"; + +/** + * Coerce any OWS Solana address brand (or raw base58) to Kit {@link Address}. + * Re-validates via `@solana/addresses` so invalid strings throw at the boundary. + */ +export class SolanaAddressUtils { + static toKitAddress( + value: + | SolanaAccountAddress + | SolanaProgramAddress + | SolanaMintAddress + | SolanaATAAddress + | string, + ): Address { + return address(typeof value === "string" ? value : String(value)); + } + + static asAccountAddress(value: Address | string): SolanaAccountAddress { + return makeSolanaAccountAddress(address(String(value)) as string); + } + + static asProgramAddress(value: Address | string): SolanaProgramAddress { + return makeSolanaProgramAddress(address(String(value)) as string); + } + + static asMintAddress(value: Address | string): SolanaMintAddress { + return makeSolanaMintAddress(address(String(value)) as string); + } + + static asAtaAddress(value: Address | string): SolanaATAAddress { + return makeSolanaATAAddress(address(String(value)) as string); + } +} diff --git a/packages/ows-types/src/utils/credentials/CredentialCryptoUtils.ts b/packages/ows-types/src/utils/credentials/CredentialCryptoUtils.ts new file mode 100644 index 0000000..c92ce32 --- /dev/null +++ b/packages/ows-types/src/utils/credentials/CredentialCryptoUtils.ts @@ -0,0 +1,155 @@ +import type { IHolderSigner } from "../../credentials/holder-signer.js"; +import { Base64UrlEncodedString } from "../../primitives/Base64UrlEncodedString.js"; +import type { ED25519PublicKey } from "../../primitives/ED25519PublicKey.js"; +import { HexString } from "../../primitives/HexString.js"; +import { ConversionUtils } from "../ConversionUtils.js"; +import type { Hasher, SaltGenerator, Signer, Verifier } from "@sd-jwt/core"; + +/** Signing Layer hooks used to build an SD-JWT VC {@link IHolderSigner}. */ +export interface IOwsEd25519HolderSignerDeps { + getEd25519PublicKeyHex: () => Promise; + signDigest: ( + digests: Array<{ digestData: HexString; scheme?: "ed25519" }>, + ) => Promise>; +} + +function toHashBytes(data: string | ArrayBuffer): Uint8Array { + return typeof data === "string" + ? new TextEncoder().encode(data) + : new Uint8Array(data); +} + +/** Web Crypto expects BufferSource backed by ArrayBuffer, not ArrayBufferLike. */ +function asBufferSource(bytes: Uint8Array): BufferSource { + return new Uint8Array(bytes); +} + +/** + * Web Crypto helpers for SD-JWT VC / OID4 credential flows (hash, salt, Ed25519). + */ +export class CredentialCryptoUtils { + /** + * SHA-256 hasher for `@sd-jwt/core` (`Hasher` contract). + * Algorithm strings must include `sha-256`. + */ + static readonly hasher: Hasher = async (data, alg) => { + if (!alg.includes("sha-256")) { + throw new Error( + `CredentialCryptoUtils.hasher: unsupported algorithm ${alg}`, + ); + } + const digest = await crypto.subtle.digest( + "SHA-256", + asBufferSource(toHashBytes(data)), + ); + return new Uint8Array(digest); + }; + + /** Random salt generator for SD-JWT disclosures (`SaltGenerator` contract). */ + static readonly saltGenerator: SaltGenerator = async (length) => { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return ConversionUtils.bytesToBase64Url(bytes); + }; + + static importEd25519PrivateKey(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey("jwk", jwk, { name: "Ed25519" }, false, [ + "sign", + ]); + } + + static importEd25519PublicKey(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey("jwk", jwk, { name: "Ed25519" }, false, [ + "verify", + ]); + } + + /** Sign UTF-8 `data` with an Ed25519 private JWK; returns branded base64url. */ + static async signEd25519( + privateJwk: JsonWebKey, + data: string, + ): Promise { + const key = await CredentialCryptoUtils.importEd25519PrivateKey(privateJwk); + const signature = await crypto.subtle.sign( + "Ed25519", + key, + asBufferSource(new TextEncoder().encode(data)), + ); + return ConversionUtils.bytesToBase64Url(new Uint8Array(signature)); + } + + /** Verify an Ed25519 signature over UTF-8 `data`. */ + static async verifyEd25519( + publicJwk: JsonWebKey, + data: string, + signatureBase64Url: Base64UrlEncodedString, + ): Promise { + const key = await CredentialCryptoUtils.importEd25519PublicKey(publicJwk); + return crypto.subtle.verify( + "Ed25519", + key, + asBufferSource(ConversionUtils.base64UrlToBytes(signatureBase64Url)), + asBufferSource(new TextEncoder().encode(data)), + ); + } + + /** `@sd-jwt/core` `Signer` backed by an Ed25519 private JWK. */ + static createEd25519Signer(privateJwk: JsonWebKey): Signer { + return (data) => CredentialCryptoUtils.signEd25519(privateJwk, data); + } + + /** `@sd-jwt/core` `Verifier` backed by an Ed25519 public JWK. */ + static createEd25519Verifier(publicJwk: JsonWebKey): Verifier { + return (data, sig) => + CredentialCryptoUtils.verifyEd25519( + publicJwk, + data, + Base64UrlEncodedString(sig), + ); + } + + /** + * Bridges OWS Signing Layer Ed25519 (`signDigest` / public key hex) to + * SD-JWT VC {@link IHolderSigner} key binding. + */ + static createOwsEd25519HolderSigner( + deps: IOwsEd25519HolderSignerDeps, + ): IHolderSigner { + let cachedJwk: JsonWebKey | undefined; + + return { + async publicKeyJwk() { + if (cachedJwk) { + return cachedJwk; + } + const hex = await deps.getEd25519PublicKeyHex(); + const bytes = ConversionUtils.hexToBytes(HexString(hex)); + if (bytes.length !== 32) { + throw new Error( + "CredentialCryptoUtils.createOwsEd25519HolderSigner: ed25519 public key must be 32 bytes", + ); + } + cachedJwk = { + kty: "OKP", + crv: "Ed25519", + x: ConversionUtils.bytesToBase64Url(bytes), + }; + return cachedJwk; + }, + async signKbJwt(unsignedJwt) { + const digestHex = ConversionUtils.utf8ToHex(unsignedJwt); + const [result] = await deps.signDigest([ + { digestData: digestHex, scheme: "ed25519" }, + ]); + if (!result) { + throw new Error( + "CredentialCryptoUtils.createOwsEd25519HolderSigner: empty signDigest result", + ); + } + return ConversionUtils.bytesToBase64Url( + ConversionUtils.hexToBytes(result.signature), + ); + }, + }; + } +} diff --git a/packages/ows-types/src/utils/credentials/PresentationUtils.ts b/packages/ows-types/src/utils/credentials/PresentationUtils.ts new file mode 100644 index 0000000..e761fdd --- /dev/null +++ b/packages/ows-types/src/utils/credentials/PresentationUtils.ts @@ -0,0 +1,201 @@ +import type { PresentationFrame } from "@sd-jwt/core"; +import { decodeJwt, decodeSdJwt, getClaims, splitSdJwt } from "@sd-jwt/core"; +import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"; +import type { IHolderSigner } from "../../credentials/holder-signer.js"; +import type { PresentationDefinition } from "../../credentials/presentation.js"; +import type { StoredCredential } from "../../credentials/credential.js"; +import type { CredentialClaimName } from "../../primitives/CredentialClaimName.js"; +import { SdJwtVcPresentationString } from "../../primitives/SdJwtVcPresentationString.js"; +import { CredentialCryptoUtils } from "./CredentialCryptoUtils.js"; + +export type SdJwtVcIssuerClaims = { + iss?: string; + vct?: string; + iat?: number; + cnf?: { jwk?: JsonWebKey }; +}; + +export type KbJwtClaims = { + iat?: number; + aud?: string; + nonce?: string; + sd_hash?: string; +}; + +export type BuildSdJwtVcPresentationInput = { + credential: StoredCredential; + definition: PresentationDefinition; + holderSigner: IHolderSigner; +}; + +export type BuildSdJwtVcPresentationResult = { + presentation: SdJwtVcPresentationString; + disclosedClaims: CredentialClaimName[]; +}; + +/** JWT / SD-JWT envelope keys excluded from credential-subject maps. */ +const SD_JWT_ENVELOPE_KEYS = new Set([ + "iss", + "iat", + "exp", + "nbf", + "vct", + "cnf", + "status", + "_sd", + "_sd_alg", + "aud", + "sub", + "jti", +]); + +/** + * SD-JWT VC presentation helpers: decode compact strings, unpack selective + * disclosures, and build holder presentations with key binding. + */ +export class PresentationUtils { + /** Decodes issuer JWT payload claims from an SD-JWT VC compact string. */ + static decodeIssuerClaims( + encoded: SdJwtVcPresentationString | string, + ): SdJwtVcIssuerClaims { + const { jwt } = splitSdJwt(String(encoded)); + const { payload } = decodeJwt, SdJwtVcIssuerClaims>( + jwt, + ); + return payload; + } + + /** Reads `cnf.jwk` from an SD-JWT VC (presentation or full credential). */ + static extractHolderJwk( + encoded: SdJwtVcPresentationString | string, + ): JsonWebKey | undefined { + return PresentationUtils.decodeIssuerClaims(encoded).cnf?.jwk; + } + + /** + * Decodes the key-binding JWT (`kb+jwt`) claims from a presentation. + * Returns `undefined` when no kb segment is present. + */ + static extractKbJwtClaims( + encoded: SdJwtVcPresentationString | string, + ): KbJwtClaims | undefined { + const parts = String(encoded).split("~"); + const kb = parts[parts.length - 1]; + if (!kb || !kb.includes(".")) { + return undefined; + } + try { + const { payload } = decodeJwt, KbJwtClaims>(kb); + return payload; + } catch { + return undefined; + } + } + + /** + * Unpack an SD-JWT VC compact string into a credential-subject claim map. + * + * Selectively disclosed claims live only in `~` disclosure segments (hashed as + * `_sd` in the JWT). Callers that peek at the JWT payload alone miss them. + */ + static async unpackSubject( + sdJwt: string, + ): Promise> { + const decoded = await decodeSdJwt(sdJwt, CredentialCryptoUtils.hasher); + const claims = await getClaims>( + decoded.jwt.payload, + decoded.disclosures, + CredentialCryptoUtils.hasher, + ); + + const subject: Record = {}; + for (const [key, value] of Object.entries(claims)) { + if (SD_JWT_ENVELOPE_KEYS.has(key)) continue; + subject[key] = value; + } + return subject; + } + + /** Builds a selective-disclosure SD-JWT VC presentation with holder key binding. */ + static async build( + input: BuildSdJwtVcPresentationInput, + ): Promise { + const { credential, definition, holderSigner } = input; + + if (credential.format !== "sd-jwt-vc") { + throw new Error( + `PresentationUtils.build: unsupported format ${credential.format}`, + ); + } + + // Prefer unpacked SD disclosures; selectively disclosed claims are absent + // from the JWT payload (and thus from a naïve credentialSubject peek). + const unpacked = await PresentationUtils.unpackSubject(credential.payload); + const subject = + Object.keys(unpacked).length > 0 + ? unpacked + : credential.semantic.credentialSubject; + const disclosedClaims = definition.requestedClaims.filter( + (claim) => claim in subject, + ); + + if (disclosedClaims.length === 0) { + throw new Error( + "PresentationUtils.build: no requested claims present in credential", + ); + } + + const presentationFrame = PresentationUtils.presentationFrameFromClaims( + definition.requestedClaims, + subject, + ); + + const sdjwt = new SDJwtVcInstance({ + kbSigner: (data) => holderSigner.signKbJwt(data), + kbSignAlg: "EdDSA", + hasher: CredentialCryptoUtils.hasher, + hashAlg: "sha-256", + saltGenerator: CredentialCryptoUtils.saltGenerator, + }); + + const nonce = definition.nonce; + const audience = definition.audience ?? definition.verifier.id; + if (!nonce) { + throw new Error( + "PresentationUtils.build: presentation definition requires nonce for key binding", + ); + } + + const presentation = await sdjwt.present( + credential.payload, + presentationFrame, + { + kb: { + payload: { + iat: Math.floor(Date.now() / 1000), + aud: audience, + nonce, + }, + }, + }, + ); + + return { + presentation: SdJwtVcPresentationString(presentation), + disclosedClaims, + }; + } + + private static presentationFrameFromClaims( + requestedClaims: CredentialClaimName[], + subject: Record, + ): PresentationFrame> { + const frame: Record = {}; + for (const claim of requestedClaims) { + if (claim in subject) { + frame[claim] = true; + } + } + return frame; + } +} diff --git a/packages/ows-types/src/utils/credentials/ProofUtils.ts b/packages/ows-types/src/utils/credentials/ProofUtils.ts new file mode 100644 index 0000000..5c74753 --- /dev/null +++ b/packages/ows-types/src/utils/credentials/ProofUtils.ts @@ -0,0 +1,192 @@ +import { Base64UrlEncodedString } from "../../primitives/Base64UrlEncodedString.js"; +import { JWKThumbprint } from "../../primitives/JWKThumbprint.js"; +import { + OID4VCI_PROOF_JWT_TYP, + type BuildOid4vciProofJwtInput, + type VerifyOid4vciProofJwtInput, + type VerifyOid4vciProofJwtResult, +} from "../../credentials/oid4vci-proof.js"; +import { ConversionUtils } from "../ConversionUtils.js"; +import { CredentialCryptoUtils } from "./CredentialCryptoUtils.js"; + +type ProofHeader = { + typ?: string; + alg?: string; + jwk?: JsonWebKey; +}; + +type ProofPayload = { + aud?: string; + iat?: number; + nonce?: string; +}; + +function publicJwkOnly(jwk: JsonWebKey): JsonWebKey { + const { kty, crv, x, y, e, n } = jwk; + const pub: JsonWebKey = { kty }; + if (crv !== undefined) pub.crv = crv; + if (x !== undefined) pub.x = x; + if (y !== undefined) pub.y = y; + if (e !== undefined) pub.e = e; + if (n !== undefined) pub.n = n; + return pub; +} + +function requiredMembersForThumbprint( + jwk: JsonWebKey, +): Record { + const kty = jwk.kty; + if (!kty) { + throw new Error("ProofUtils.jwkThumbprint: missing kty"); + } + + if (kty === "OKP") { + if (!jwk.crv || !jwk.x) { + throw new Error("ProofUtils.jwkThumbprint: OKP requires crv and x"); + } + return { crv: jwk.crv, kty, x: jwk.x }; + } + + if (kty === "EC") { + if (!jwk.crv || !jwk.x || !jwk.y) { + throw new Error("ProofUtils.jwkThumbprint: EC requires crv, x, and y"); + } + return { crv: jwk.crv, kty, x: jwk.x, y: jwk.y }; + } + + if (kty === "RSA") { + if (!jwk.e || !jwk.n) { + throw new Error("ProofUtils.jwkThumbprint: RSA requires e and n"); + } + return { e: jwk.e, kty, n: jwk.n }; + } + + throw new Error(`ProofUtils.jwkThumbprint: unsupported kty ${kty}`); +} + +/** + * OID4VCI proof-of-possession and JWK identity helpers for credential issuance. + */ +export class ProofUtils { + /** + * Builds an OID4VCI `openid4vci-proof+jwt` proving possession of the holder key. + * Signed with {@link IHolderSigner.signKbJwt} (EdDSA signing input). + */ + static async buildOid4vciProofJwt( + input: BuildOid4vciProofJwtInput, + ): Promise { + const holderJwk = publicJwkOnly(await input.holderSigner.publicKeyJwk()); + const header: ProofHeader = { + typ: OID4VCI_PROOF_JWT_TYP, + alg: "EdDSA", + jwk: holderJwk, + }; + const payload: ProofPayload = { + aud: input.audience, + iat: Math.floor(Date.now() / 1000), + }; + if (input.nonce !== undefined) { + payload.nonce = input.nonce; + } + + const signingInput = `${ConversionUtils.encodeJsonBase64Url(header)}.${ConversionUtils.encodeJsonBase64Url(payload)}`; + const signature = await input.holderSigner.signKbJwt(signingInput); + return `${signingInput}.${signature}`; + } + + /** Verifies an OID4VCI proof JWT and returns the holder public key from the header. */ + static async verifyOid4vciProofJwt( + input: VerifyOid4vciProofJwtInput, + ): Promise { + const reasons: string[] = []; + const parts = input.jwt.split("."); + if (parts.length !== 3) { + return { + valid: false, + reasons: ["OID4VCI proof JWT must have three segments"], + }; + } + + const [headerB64, payloadB64, signatureB64] = parts as [ + string, + string, + string, + ]; + let header: ProofHeader; + let payload: ProofPayload; + try { + header = ConversionUtils.decodeJsonBase64Url(headerB64); + payload = ConversionUtils.decodeJsonBase64Url(payloadB64); + } catch { + return { + valid: false, + reasons: ["OID4VCI proof JWT header/payload is not valid JSON"], + }; + } + + if (header.typ !== OID4VCI_PROOF_JWT_TYP) { + reasons.push(`Unexpected proof typ: ${header.typ ?? "(missing)"}`); + } + if (header.alg !== "EdDSA") { + reasons.push(`Unsupported proof alg: ${header.alg ?? "(missing)"}`); + } + if (!header.jwk) { + reasons.push("OID4VCI proof JWT header missing jwk"); + return { valid: false, reasons }; + } + + if (payload.aud !== input.expectedAudience) { + reasons.push( + `Proof audience mismatch: expected ${input.expectedAudience}, got ${payload.aud ?? "(missing)"}`, + ); + } + if ( + input.expectedNonce !== undefined && + payload.nonce !== input.expectedNonce + ) { + reasons.push( + `Proof nonce mismatch: expected ${input.expectedNonce}, got ${payload.nonce ?? "(missing)"}`, + ); + } + + const signingInput = `${headerB64}.${payloadB64}`; + const holderPublicKeyJwk = publicJwkOnly(header.jwk); + const sigOk = await CredentialCryptoUtils.verifyEd25519( + holderPublicKeyJwk, + signingInput, + Base64UrlEncodedString(signatureB64), + ); + if (!sigOk) { + reasons.push("OID4VCI proof JWT signature invalid"); + } + + return { + valid: reasons.length === 0, + holderPublicKeyJwk, + reasons, + }; + } + + /** + * RFC 7638 JWK Thumbprint (SHA-256), returned as branded base64url. + * Supports OKP (Ed25519/X25519), EC, and RSA public members used in OWS. + */ + static async jwkThumbprint(jwk: JsonWebKey): Promise { + const members = requiredMembersForThumbprint(jwk); + const canonical = JSON.stringify(members, Object.keys(members).sort()); + const digest = await CredentialCryptoUtils.hasher(canonical, "sha-256"); + return JWKThumbprint(ConversionUtils.bytesToBase64Url(digest)); + } + + /** True when two JWKs share the same RFC 7638 thumbprint. */ + static async jwksEqualsByThumbprint( + a: JsonWebKey, + b: JsonWebKey, + ): Promise { + const [ta, tb] = await Promise.all([ + ProofUtils.jwkThumbprint(a), + ProofUtils.jwkThumbprint(b), + ]); + return ta === tb; + } +} diff --git a/packages/ows-types/src/utils/index.ts b/packages/ows-types/src/utils/index.ts new file mode 100644 index 0000000..134eb5a --- /dev/null +++ b/packages/ows-types/src/utils/index.ts @@ -0,0 +1,19 @@ +export { ChainUtils } from "./ChainUtils.js"; +export { + ConversionUtils, + type EVMAddressLike, + type SolanaPubkeyLike, +} from "./ConversionUtils.js"; +export { SolanaAddressUtils } from "./SolanaAddressUtils.js"; +export { + CredentialCryptoUtils, + type IOwsEd25519HolderSignerDeps, +} from "./credentials/CredentialCryptoUtils.js"; +export { + PresentationUtils, + type BuildSdJwtVcPresentationInput, + type BuildSdJwtVcPresentationResult, + type SdJwtVcIssuerClaims, + type KbJwtClaims, +} from "./credentials/PresentationUtils.js"; +export { ProofUtils } from "./credentials/ProofUtils.js"; diff --git a/packages/ows-types/test/analytics.test.ts b/packages/ows-types/test/analytics.test.ts new file mode 100644 index 0000000..d353c8c --- /dev/null +++ b/packages/ows-types/test/analytics.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + AnalyticsEventId, + DomainString, + EVMAccountAddress, + OWSAnalyticsEvent, + UnixTimestamp, + deserializeAnalyticsEvent, + serializeRpc, + type IOWSAnalyticsEvent, +} from "../src/index.js"; + +/** Concrete subclass for constructing base events in tests. */ +class TestAnalyticsEvent extends OWSAnalyticsEvent {} + +describe("OWSAnalyticsEvent", () => { + it("mints eventId and timestamp", () => { + const event = new TestAnalyticsEvent( + "AccountCreated", + DomainString("app.example.com"), + ); + assert.equal(event.name, "AccountCreated"); + assert.equal(event.hostDomain, DomainString("app.example.com")); + assert.equal(typeof event.eventId, "string"); + assert.ok(event.eventId.length > 0); + assert.equal(typeof event.timestamp, "number"); + }); + + it("accepts explicit eventId and timestamp", () => { + const event = new TestAnalyticsEvent( + "AccountCreated", + DomainString("app.example.com"), + { + eventId: AnalyticsEventId("11111111-1111-4111-8111-111111111111"), + timestamp: UnixTimestamp(1_719_792_000), + }, + ); + assert.equal( + event.eventId, + AnalyticsEventId("11111111-1111-4111-8111-111111111111"), + ); + assert.equal(event.timestamp, UnixTimestamp(1_719_792_000)); + }); + + it("serializes subclass fields for the wire", () => { + class AccountCreatedEvent extends OWSAnalyticsEvent { + constructor( + hostDomain: DomainString, + public readonly accountAddress: EVMAccountAddress, + ) { + super("AccountCreated", hostDomain); + } + } + + const event = new AccountCreatedEvent( + DomainString("app.example.com"), + EVMAccountAddress("0xabc"), + ); + const roundTrip = deserializeAnalyticsEvent(serializeRpc(event)); + assert.equal(roundTrip.name, "AccountCreated"); + assert.equal(roundTrip.accountAddress, "0xabc"); + assert.equal(roundTrip.hostDomain, DomainString("app.example.com")); + }); +}); + +describe("deserializeAnalyticsEvent", () => { + it("brands base fields and preserves rich extras", () => { + const raw = { + eventId: "11111111-1111-4111-8111-111111111111", + timestamp: 1_719_792_000, + hostDomain: "app.example.com", + name: "TransactionSubmitted", + accountAddress: "0xabc", + chainId: "0x2105", + to: "0xdef", + txHash: "0xhash", + durationMs: 420, + }; + + const event = deserializeAnalyticsEvent(serializeRpc(raw)); + + assert.equal(event.eventId, AnalyticsEventId(raw.eventId)); + assert.equal(event.timestamp, UnixTimestamp(raw.timestamp)); + assert.equal(event.hostDomain, DomainString(raw.hostDomain)); + assert.equal(event.name, "TransactionSubmitted"); + assert.equal(event.accountAddress, "0xabc"); + assert.equal(event.chainId, "0x2105"); + assert.equal(event.to, "0xdef"); + assert.equal(event.txHash, "0xhash"); + assert.equal(event.durationMs, 420); + }); + + it("accepts an already-parsed object", () => { + const event: IOWSAnalyticsEvent = deserializeAnalyticsEvent({ + eventId: "22222222-2222-4222-8222-222222222222", + timestamp: 100, + hostDomain: "localhost", + name: "AccountCreated", + }); + assert.equal(event.name, "AccountCreated"); + assert.equal(event.hostDomain, DomainString("localhost")); + }); + + it("rejects missing required fields", () => { + assert.throws(() => + deserializeAnalyticsEvent({ + eventId: "33333333-3333-4333-8333-333333333333", + timestamp: 1, + hostDomain: "app.example.com", + }), + ); + }); +}); diff --git a/packages/ows-types/test/chain-utils.test.ts b/packages/ows-types/test/chain-utils.test.ts new file mode 100644 index 0000000..f577a8b --- /dev/null +++ b/packages/ows-types/test/chain-utils.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + BITCOIN_CAIP2, + BITCOIN_MAINNET_CHAIN_ID, + BITCOIN_TESTNET_CHAIN_ID, + ChainUtils, + EChainTechnology, + EVMChainId, + OwsInvalidParamsError, + SOLANA_CAIP2, + SOLANA_DEVNET_CHAIN_ID, + SOLANA_MAINNET_CHAIN_ID, +} from "../src/index.js"; + +describe("ChainUtils", () => { + it("narrows Bitcoin sentinels", () => { + assert.equal(ChainUtils.isBitcoinChainId("Bitcoin"), true); + assert.equal(ChainUtils.isBitcoinChainId("BitcoinTestnet"), true); + assert.equal(ChainUtils.isBitcoinChainId(-1), false); + assert.equal(ChainUtils.isBitcoinChainId("-1"), false); + assert.equal( + ChainUtils.asBitcoinChainId("BitcoinTestnet"), + BITCOIN_TESTNET_CHAIN_ID, + ); + assert.throws(() => ChainUtils.asBitcoinChainId("0x1"), /Invalid BitcoinChainId/); + }); + + it("narrows EVM hex chain ids", () => { + assert.equal(ChainUtils.isEVMChainId("0x2105"), true); + assert.equal(ChainUtils.isEVMChainId("Bitcoin"), false); + assert.equal(ChainUtils.isEVMChainId(" 0x2105"), false); + assert.equal(ChainUtils.asEVMChainId("0x2105"), EVMChainId("0x2105")); + assert.equal(ChainUtils.asEVMChainId("0xAa"), EVMChainId("0xaa")); + assert.equal(ChainUtils.asEVMChainId("0x01"), EVMChainId("0x1")); + assert.throws( + () => ChainUtils.asEVMChainId("Bitcoin"), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + }); + + it("normalizes number, bigint, decimal, and hex chain ids", () => { + assert.equal(ChainUtils.asEVMChainId(8453), EVMChainId("0x2105")); + assert.equal(ChainUtils.asEVMChainId(8453n), EVMChainId("0x2105")); + assert.equal(ChainUtils.asEVMChainId("8453"), EVMChainId("0x2105")); + assert.equal(ChainUtils.asEVMChainId("0x0aa36a7"), EVMChainId("0xaa36a7")); + assert.equal(ChainUtils.asEVMChainId("11155111"), EVMChainId("0xaa36a7")); + }); + + it("trims whitespace before normalizing", () => { + assert.equal(ChainUtils.asEVMChainId(" 0x2105 "), EVMChainId("0x2105")); + assert.equal(ChainUtils.asEVMChainId("\t8453\n"), EVMChainId("0x2105")); + }); + + it("rejects invalid EVM chain ids", () => { + assert.throws( + () => ChainUtils.asEVMChainId(-1), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + assert.throws( + () => ChainUtils.asEVMChainId(1.5), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + assert.throws( + () => ChainUtils.asEVMChainId(-1n), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + assert.throws( + () => ChainUtils.asEVMChainId(""), + (error: unknown) => error instanceof OwsInvalidParamsError, + ); + }); + + it("narrows Solana CAIP-2 sentinels", () => { + assert.equal(ChainUtils.isSolanaChainId("solana:mainnet"), true); + assert.equal(ChainUtils.isSolanaChainId(SOLANA_DEVNET_CHAIN_ID), true); + assert.equal(ChainUtils.isSolanaChainId("Solana"), false); + assert.equal(ChainUtils.isSolanaChainId("0x1"), false); + assert.equal( + ChainUtils.asSolanaChainId("solana:mainnet"), + SOLANA_MAINNET_CHAIN_ID, + ); + assert.throws(() => ChainUtils.asSolanaChainId("Bitcoin"), /Invalid SolanaChainId/); + }); + + it("maps technology and CAIP-2", () => { + assert.equal( + ChainUtils.technologyFor(BITCOIN_MAINNET_CHAIN_ID), + EChainTechnology.Bitcoin, + ); + assert.equal( + ChainUtils.technologyFor(SOLANA_MAINNET_CHAIN_ID), + EChainTechnology.Solana, + ); + assert.equal( + ChainUtils.technologyFor(EVMChainId("0x1")), + EChainTechnology.Evm, + ); + assert.equal( + ChainUtils.bitcoinChainIdToCaip2(BITCOIN_MAINNET_CHAIN_ID), + BITCOIN_CAIP2.MAINNET, + ); + assert.equal( + ChainUtils.caip2ToBitcoinChainId(BITCOIN_CAIP2.TESTNET), + BITCOIN_TESTNET_CHAIN_ID, + ); + assert.equal( + ChainUtils.solanaChainIdToCaip2(SOLANA_MAINNET_CHAIN_ID), + SOLANA_CAIP2.MAINNET, + ); + assert.equal( + ChainUtils.caip2ToSolanaChainId(SOLANA_CAIP2.DEVNET), + SOLANA_DEVNET_CHAIN_ID, + ); + }); +}); diff --git a/packages/ows-types/test/conversion-utils.test.ts b/packages/ows-types/test/conversion-utils.test.ts new file mode 100644 index 0000000..b23c50d --- /dev/null +++ b/packages/ows-types/test/conversion-utils.test.ts @@ -0,0 +1,120 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + ConversionUtils, + EVMAccountAddress, + EVMContractAddress, + HexString, + SolanaAccountAddress, + SolanaATAAddress, +} from "../src/index.js"; + +describe("ConversionUtils.addressToBytes32Hex", () => { + it("left-pads a 20-byte address to 32 bytes", () => { + const address = "0x4200000000000000000000000000000000000006" as const; + const result = ConversionUtils.addressToBytes32Hex(address); + assert.equal( + result, + HexString( + "0x0000000000000000000000004200000000000000000000000000000000000006", + ), + ); + assert.equal((String(result).length - 2) / 2, 32); + }); + + it("accepts checksummed addresses and lowercases the output", () => { + const checksummed = EVMAccountAddress( + "0xAbCdEf0123456789AbCdEf0123456789aBcDeF01", + ); + const result = ConversionUtils.addressToBytes32Hex(checksummed); + assert.equal( + result, + HexString( + "0x000000000000000000000000abcdef0123456789abcdef0123456789abcdef01", + ), + ); + }); + + it("accepts EVMContractAddress", () => { + const contract = EVMContractAddress( + "0x4200000000000000000000000000000000000006", + ); + const result = ConversionUtils.addressToBytes32Hex(contract); + assert.equal( + result, + HexString( + "0x0000000000000000000000004200000000000000000000000000000000000006", + ), + ); + }); + + it("rejects non-20-byte hex", () => { + assert.throws( + () => ConversionUtils.addressToBytes32Hex("0x1234" as `0x${string}`), + /20-byte/, + ); + }); + + it("rejects hex without 0x prefix at runtime via failed length match", () => { + assert.throws( + () => + ConversionUtils.addressToBytes32Hex( + "4200000000000000000000000000000000000006" as `0x${string}`, + ), + /20-byte/, + ); + }); +}); + +describe("ConversionUtils.addressToSolanaPubkeyBytes", () => { + it("left-pads an EVM address into 32 pubkey bytes", () => { + const address = EVMAccountAddress( + "0x4200000000000000000000000000000000000006", + ); + const bytes = ConversionUtils.addressToSolanaPubkeyBytes(address); + assert.equal(bytes.length, 32); + assert.deepEqual( + bytes, + ConversionUtils.hexToBytes( + HexString( + "0x0000000000000000000000004200000000000000000000000000000000000006", + ), + ), + ); + }); +}); + +describe("ConversionUtils.solanaAccountToBytes32", () => { + it("encodes a Solana account as bytes32 hex", () => { + const account = SolanaAccountAddress( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + ); + const hex = ConversionUtils.solanaAccountToBytes32(account); + assert.equal((String(hex).length - 2) / 2, 32); + const ata = SolanaATAAddress( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + ); + assert.equal( + ConversionUtils.solanaAccountToBytes32(ata), + hex, + ); + }); +}); + +describe("ConversionUtils hex roundtrip", () => { + it("bytesToHex / hexToBytes", () => { + const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const hex = ConversionUtils.bytesToHex(bytes); + assert.equal(hex, HexString("0xdeadbeef")); + assert.deepEqual(ConversionUtils.hexToBytes(hex), bytes); + }); +}); + +describe("ConversionUtils base64 roundtrip", () => { + it("bytesToBase64 / base64ToBytes", () => { + const bytes = new TextEncoder().encode("Hello World"); + const encoded = ConversionUtils.bytesToBase64(bytes); + assert.equal(encoded, "SGVsbG8gV29ybGQ="); + assert.deepEqual(ConversionUtils.base64ToBytes(encoded), bytes); + }); +}); diff --git a/packages/ows-types/test/demo-fixtures.ts b/packages/ows-types/test/demo-fixtures.ts new file mode 100644 index 0000000..d2b4f2a --- /dev/null +++ b/packages/ows-types/test/demo-fixtures.ts @@ -0,0 +1,77 @@ +import { SDJwtVcInstance } from "@sd-jwt/sd-jwt-vc"; +import type { CredentialClaimName } from "../src/primitives/CredentialClaimName.js"; +import { CredentialCryptoUtils } from "../src/utils/credentials/CredentialCryptoUtils.js"; + +/** TEST ONLY — matches examples/shared demo issuer key. */ +export const DEMO_ISSUER_PRIVATE_JWK: JsonWebKey = { + crv: "Ed25519", + d: "pcLAFtmJw-OtcPm7taGhEDocf63HfBk5TMXWSoF6rvw", + x: "ObRX6jKS0AbsXx3ICSIiozuqzYUXXrQhlj4GkeqXfbs", + kty: "OKP", +}; + +export const DEMO_ISSUER_PUBLIC_JWK: JsonWebKey = { + crv: "Ed25519", + x: "ObRX6jKS0AbsXx3ICSIiozuqzYUXXrQhlj4GkeqXfbs", + kty: "OKP", +}; + +export const DEMO_HOLDER_PRIVATE_JWK: JsonWebKey = { + crv: "Ed25519", + d: "G8_8M9RjEA5L5NIeAoB24C9yOb8KPuRoh9y7SidoXJA", + x: "3dmWOAMTtkMxm88aJ9QhlK5SWimNXt6-WTsI4eFHwC8", + kty: "OKP", +}; + +export const DEMO_HOLDER_PUBLIC_JWK: JsonWebKey = { + crv: "Ed25519", + x: "3dmWOAMTtkMxm88aJ9QhlK5SWimNXt6-WTsI4eFHwC8", + kty: "OKP", +}; + +export async function issueDemoSdJwtVc(input: { + issuer: string; + vct: string; + claims: Record; + disclosableClaims: CredentialClaimName[]; + holderPublicKeyJwk: JsonWebKey; +}): Promise { + const sdjwt = new SDJwtVcInstance({ + signer: CredentialCryptoUtils.createEd25519Signer(DEMO_ISSUER_PRIVATE_JWK), + signAlg: "EdDSA", + verifier: CredentialCryptoUtils.createEd25519Verifier(DEMO_ISSUER_PUBLIC_JWK), + hasher: CredentialCryptoUtils.hasher, + hashAlg: "sha-256", + saltGenerator: CredentialCryptoUtils.saltGenerator, + }); + + const iat = Math.floor(Date.now() / 1000); + return sdjwt.issue( + { + iss: input.issuer, + iat, + vct: input.vct, + cnf: { jwk: input.holderPublicKeyJwk }, + ...input.claims, + } as Parameters[0], + { _sd: input.disclosableClaims.map(String) } as Parameters< + SDJwtVcInstance["issue"] + >[1], + ); +} + +export function createDemoHolderSigner() { + const publicJwk = { ...DEMO_HOLDER_PRIVATE_JWK }; + delete publicJwk.d; + return { + async publicKeyJwk() { + return publicJwk; + }, + async signKbJwt(unsignedJwt: string) { + return CredentialCryptoUtils.signEd25519( + DEMO_HOLDER_PRIVATE_JWK, + unsignedJwt, + ); + }, + }; +} diff --git a/packages/ows-types/test/eip1193-events.test.ts b/packages/ows-types/test/eip1193-events.test.ts new file mode 100644 index 0000000..4e16bb4 --- /dev/null +++ b/packages/ows-types/test/eip1193-events.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + deserializeEip1193EventNotification, + serializeRpc, +} from "../src/index.js"; + +describe("deserializeEip1193EventNotification", () => { + it("round-trips chainChanged via serializeRpc", () => { + const payload = { + event: "chainChanged", + params: ["0x2105"], + }; + const notification = deserializeEip1193EventNotification( + serializeRpc(payload), + ); + assert.equal(notification.event, "chainChanged"); + assert.deepEqual(notification.params, ["0x2105"]); + }); + + it("accepts an already-parsed object", () => { + const notification = deserializeEip1193EventNotification({ + event: "accountsChanged", + params: [["0xabc"]], + }); + assert.equal(notification.event, "accountsChanged"); + assert.deepEqual(notification.params, [["0xabc"]]); + }); + + it("rejects missing event or params", () => { + assert.throws(() => + deserializeEip1193EventNotification({ event: "chainChanged" }), + ); + assert.throws(() => + deserializeEip1193EventNotification({ params: ["0x1"] }), + ); + }); +}); diff --git a/packages/ows-types/test/eip1193-requests.test.ts b/packages/ows-types/test/eip1193-requests.test.ts new file mode 100644 index 0000000..44b3d9a --- /dev/null +++ b/packages/ows-types/test/eip1193-requests.test.ts @@ -0,0 +1,81 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + EVMAccountAddress, + EVMChainId, + HexString, + IEVMTransactionRequestSchema, + type EIP1193RequestArgsFor, + type EIP1193Requests, + type EVMTransactionHash, + type IEVMTransactionRequest, +} from "../src/index.js"; + +describe("EIP1193Requests eth_sendTransaction", () => { + it("maps params to IEVMTransactionRequest and result to EVMTransactionHash", () => { + type SendArgs = EIP1193RequestArgsFor<"eth_sendTransaction">; + type SendResult = EIP1193Requests["eth_sendTransaction"]["result"]; + + const args: SendArgs = { + method: "eth_sendTransaction", + params: [ + { + to: EVMAccountAddress( + "0x1111111111111111111111111111111111111111", + ), + data: HexString("0x"), + value: HexString("0x0"), + } satisfies IEVMTransactionRequest, + ], + }; + assert.equal(args.method, "eth_sendTransaction"); + + const hash = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as EVMTransactionHash; + const result: SendResult = hash; + assert.equal(result.startsWith("0x"), true); + }); +}); + +describe("IEVMTransactionRequestSchema", () => { + it("brands addresses, hex quantities, and chainId", () => { + const tx = IEVMTransactionRequestSchema.parse({ + from: "0x1111111111111111111111111111111111111111", + to: null, + value: "0x0", + data: "0x", + gas: "0x5208", + chainId: "0x01", + type: "0x2", + accessList: [], + }); + assert.equal( + tx.from, + EVMAccountAddress("0x1111111111111111111111111111111111111111"), + ); + assert.equal(tx.to, null); + assert.equal(tx.value, HexString("0x0")); + assert.equal(tx.chainId, EVMChainId("0x1")); + assert.equal(tx.type, "0x2"); + assert.deepEqual(tx.accessList, []); + }); + + it("accepts an empty object", () => { + assert.deepEqual(IEVMTransactionRequestSchema.parse({}), {}); + }); + + it("rejects non-objects and invalid fields", () => { + assert.equal(IEVMTransactionRequestSchema.safeParse(null).success, false); + assert.equal(IEVMTransactionRequestSchema.safeParse([]).success, false); + assert.equal( + IEVMTransactionRequestSchema.safeParse({ value: 1 }).success, + false, + ); + assert.equal( + IEVMTransactionRequestSchema.safeParse({ + from: "not-an-address", + }).success, + false, + ); + }); +}); diff --git a/packages/ows-types/test/eip7715.test.ts b/packages/ows-types/test/eip7715.test.ts new file mode 100644 index 0000000..6b6943c --- /dev/null +++ b/packages/ows-types/test/eip7715.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { + EVMChainId, + EVMAccountAddress, + IAppendedCaveatConfiguration, + IExecutionPermissionRequest, +} from "../src/index.ts"; + +describe("IExecutionPermissionRequest caveats", () => { + it("accepts an optional caveats array of IAppendedCaveatConfiguration", () => { + const request: IExecutionPermissionRequest = { + chainId: "0x2105" as EVMChainId, + to: "0x1111111111111111111111111111111111111111" as EVMAccountAddress, + permission: { + type: "erc20-token-periodic", + isAdjustmentAllowed: false, + data: {}, + }, + caveats: [ + { + type: "allowedCalldata", + data: { startIndex: 4, value: "0xdeadbeef" }, + }, + { + type: "limitedCalls", + data: { limit: 3 }, + }, + ], + }; + assert.equal(request.caveats?.length, 2); + assert.equal(request.caveats?.[0]?.type, "allowedCalldata"); + }); + + it("is valid without caveats (backwards compatible)", () => { + const request: IExecutionPermissionRequest = { + chainId: "0x2105" as EVMChainId, + to: "0x1111111111111111111111111111111111111111" as EVMAccountAddress, + permission: { + type: "erc20-token-periodic", + isAdjustmentAllowed: false, + data: {}, + }, + }; + assert.equal(request.caveats, undefined); + }); + + it("IAppendedCaveatConfiguration carries type + data", () => { + const caveat: IAppendedCaveatConfiguration = { + type: "redeemer", + data: { redeemers: ["0x" + "2".repeat(40)] }, + }; + assert.equal(caveat.type, "redeemer"); + assert.ok(typeof caveat.data === "object"); + }); +}); diff --git a/packages/ows-types/test/errors.test.ts b/packages/ows-types/test/errors.test.ts new file mode 100644 index 0000000..9bc773c --- /dev/null +++ b/packages/ows-types/test/errors.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + OwsInvalidParamsError, + OwsRpcError, + OwsUserRejectedError, + RPC_ERROR_INVALID_PARAMS, + RPC_ERROR_USER_REJECTED, +} from "../src/index.js"; + +describe("OwsRpcError", () => { + it("roundtrips via toPayload/fromPayload", () => { + const error = new OwsInvalidParamsError("bad params", { field: "x" }); + const restored = OwsRpcError.fromPayload(error.toPayload()); + assert.ok(restored instanceof OwsInvalidParamsError); + assert.equal(restored.code, RPC_ERROR_INVALID_PARAMS); + assert.deepEqual(restored.data, { field: "x" }); + }); + + it("restores user rejected errors", () => { + const error = new OwsUserRejectedError(); + const restored = OwsRpcError.fromPayload(error.toPayload()); + assert.ok(restored instanceof OwsUserRejectedError); + assert.equal(restored.code, RPC_ERROR_USER_REJECTED); + }); +}); diff --git a/packages/ows-types/test/oid4vci-proof.test.ts b/packages/ows-types/test/oid4vci-proof.test.ts new file mode 100644 index 0000000..ede8292 --- /dev/null +++ b/packages/ows-types/test/oid4vci-proof.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { ProofUtils } from "../src/utils/credentials/ProofUtils.js"; +import { createDemoHolderSigner } from "./demo-fixtures.js"; + +describe("ProofUtils", () => { + it("builds and verifies a holder proof", async () => { + const holderSigner = createDemoHolderSigner(); + const audience = "https://kyc.demo.issuer.example"; + const nonce = "issuance-nonce-1"; + + const jwt = await ProofUtils.buildOid4vciProofJwt({ + holderSigner, + audience, + nonce, + }); + + const verified = await ProofUtils.verifyOid4vciProofJwt({ + jwt, + expectedAudience: audience, + expectedNonce: nonce, + }); + + assert.equal(verified.valid, true); + assert.equal(verified.reasons.length, 0); + assert.ok(verified.holderPublicKeyJwk); + + const expectedJwk = await holderSigner.publicKeyJwk(); + assert.equal( + await ProofUtils.jwksEqualsByThumbprint( + verified.holderPublicKeyJwk!, + expectedJwk, + ), + true, + ); + }); + + it("rejects wrong audience", async () => { + const holderSigner = createDemoHolderSigner(); + const jwt = await ProofUtils.buildOid4vciProofJwt({ + holderSigner, + audience: "https://kyc.demo.issuer.example", + nonce: "n1", + }); + + const verified = await ProofUtils.verifyOid4vciProofJwt({ + jwt, + expectedAudience: "https://other.issuer.example", + expectedNonce: "n1", + }); + + assert.equal(verified.valid, false); + assert.ok(verified.reasons.some((r) => r.includes("audience"))); + }); + + it("computes stable JWK thumbprints", async () => { + const holderSigner = createDemoHolderSigner(); + const jwk = await holderSigner.publicKeyJwk(); + const a = await ProofUtils.jwkThumbprint(jwk); + const b = await ProofUtils.jwkThumbprint(jwk); + assert.equal(a, b); + assert.match(a, /^[A-Za-z0-9_-]+$/); + }); +}); diff --git a/packages/ows-types/test/primitives-schemas.test.ts b/packages/ows-types/test/primitives-schemas.test.ts new file mode 100644 index 0000000..30c8795 --- /dev/null +++ b/packages/ows-types/test/primitives-schemas.test.ts @@ -0,0 +1,106 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getAddress } from "viem"; +import { + AES256CipherTextEnvelopeSchema, + AES256CipherTextSchema, + AnalyticsEventIdSchema, + Base64StringSchema, + BitcoinChainIdSchema, + BitcoinSegwitAccountAddressSchema, + EChainTechnology, + EChainTechnologySchema, + ED25519PublicKeySchema, + EVMAccountAddressSchema, + EVMChainIdSchema, + OWSChainIdSchema, + SECP256K1PublicKeySchema, + SolanaAccountAddressSchema, +} from "../src/index.ts"; + +describe("primitive Zod schemas", () => { + it("checksums EVMAccountAddress via viem getAddress", () => { + const raw = "0xabCDEF0123456789abcdef0123456789ABCDEF01"; + const parsed = EVMAccountAddressSchema.parse(raw); + assert.equal(parsed, getAddress(raw)); + }); + + it("normalizes EVMChainId leading zeros and case", () => { + assert.equal(EVMChainIdSchema.parse("0x01"), "0x1"); + assert.equal(EVMChainIdSchema.parse("0xAA"), "0xaa"); + assert.equal(EVMChainIdSchema.parse("0x13b2"), "0x13b2"); + }); + + it("accepts uncompressed SECP256K1PublicKey only", () => { + const uncompressed = ("0x04" + "11".repeat(64)) as `0x${string}`; + assert.equal(SECP256K1PublicKeySchema.parse(uncompressed), uncompressed.toLowerCase()); + assert.equal( + SECP256K1PublicKeySchema.safeParse("0x02" + "11".repeat(32)).success, + false, + ); + }); + + it("accepts 32-byte ED25519PublicKey", () => { + const key = ("0x" + "ab".repeat(32)) as `0x${string}`; + assert.equal(ED25519PublicKeySchema.parse(key), key.toLowerCase()); + assert.equal( + ED25519PublicKeySchema.safeParse("0x" + "ab".repeat(31)).success, + false, + ); + }); + + it("validates Bitcoin SegWit P2WPKH addresses", () => { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"; + assert.equal(BitcoinSegwitAccountAddressSchema.parse(addr), addr); + assert.equal( + BitcoinSegwitAccountAddressSchema.safeParse("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + .success, + false, + ); + }); + + it("validates Solana base58 addresses to 32 bytes", () => { + // well-known system program id + const addr = "11111111111111111111111111111111"; + assert.equal(SolanaAccountAddressSchema.parse(addr), addr); + assert.equal(SolanaAccountAddressSchema.safeParse("not-base58!!!").success, false); + }); + + it("accepts standard Base64String and rejects base64url alphabet", () => { + assert.equal(Base64StringSchema.parse("SGVsbG8="), "SGVsbG8="); + assert.equal(Base64StringSchema.safeParse("SGVsbG8_").success, false); + }); + + it("OWSChainIdSchema accepts EVM, Bitcoin, and Solana", () => { + assert.equal(OWSChainIdSchema.parse("0x1"), "0x1"); + assert.equal(OWSChainIdSchema.parse("Bitcoin"), "Bitcoin"); + assert.equal(OWSChainIdSchema.parse("solana:devnet"), "solana:devnet"); + assert.equal(BitcoinChainIdSchema.parse("BitcoinTestnet"), "BitcoinTestnet"); + }); + + it("AnalyticsEventIdSchema requires a UUID", () => { + const id = "550e8400-e29b-41d4-a716-446655440000"; + assert.equal(AnalyticsEventIdSchema.parse(id), id); + assert.equal(AnalyticsEventIdSchema.safeParse("not-a-uuid").success, false); + }); + + it("distinguishes AES ciphertext vs envelope", () => { + assert.equal( + AES256CipherTextSchema.parse("0xdeadbeef"), + "0xdeadbeef", + ); + assert.equal( + AES256CipherTextEnvelopeSchema.parse("ows-aes1:0x01aabb"), + "ows-aes1:0x01aabb", + ); + assert.equal( + AES256CipherTextEnvelopeSchema.safeParse("0xdeadbeef").success, + false, + ); + }); + + it("EChainTechnologySchema accepts enum values", () => { + assert.equal(EChainTechnologySchema.parse("evm"), EChainTechnology.Evm); + assert.equal(EChainTechnologySchema.safeParse("cosmos").success, false); + }); +}); diff --git a/packages/ows-types/test/sd-jwt-vc.test.ts b/packages/ows-types/test/sd-jwt-vc.test.ts new file mode 100644 index 0000000..6784c23 --- /dev/null +++ b/packages/ows-types/test/sd-jwt-vc.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { CredentialClaimName, CredentialIssuer } from "../src/index.js"; +import { PresentationUtils } from "../src/utils/credentials/PresentationUtils.js"; +import { + issueDemoSdJwtVc, + createDemoHolderSigner, +} from "./demo-fixtures.js"; + +const MOCK_KYC_ISSUER_ID = CredentialIssuer("https://kyc.demo.issuer.example"); + +describe("PresentationUtils", () => { + it("unpacks selectively disclosed claims into the subject map", async () => { + const holderSigner = createDemoHolderSigner(); + const holderJwk = await holderSigner.publicKeyJwk(); + const subject = { ageOver18: true, country: "US" }; + const payload = await issueDemoSdJwtVc({ + issuer: MOCK_KYC_ISSUER_ID, + vct: "KycCredential", + claims: subject, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + holderPublicKeyJwk: holderJwk, + }); + + const unpacked = await PresentationUtils.unpackSubject(payload); + assert.equal(unpacked.ageOver18, true); + assert.equal(unpacked.country, "US"); + }); + + it("builds selective disclosure presentation with kb+jwt", async () => { + const holderSigner = createDemoHolderSigner(); + const holderJwk = await holderSigner.publicKeyJwk(); + + const subject = { + ageOver18: true, + country: "US", + }; + + const payload = await issueDemoSdJwtVc({ + issuer: MOCK_KYC_ISSUER_ID, + vct: "KycCredential", + claims: subject, + disclosableClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + holderPublicKeyJwk: holderJwk, + }); + + const built = await PresentationUtils.build({ + credential: { + credentialId: "cred_test" as never, + format: "sd-jwt-vc", + type: ["VerifiableCredential", "KycCredential"] as never, + issuer: MOCK_KYC_ISSUER_ID, + issuedAt: new Date().toISOString() as never, + payload, + // Empty subject mimics JWT-peek storage that omitted SD disclosures. + semantic: { + type: ["VerifiableCredential", "KycCredential"] as never, + issuer: MOCK_KYC_ISSUER_ID, + credentialSubject: {}, + }, + }, + definition: { + id: "test", + verifier: { id: "https://verifier.example" as never, name: "Verifier" }, + requestedClaims: [ + CredentialClaimName("ageOver18"), + CredentialClaimName("country"), + ], + nonce: "test-nonce", + audience: "https://verifier.example", + }, + holderSigner, + }); + + assert.ok(built.presentation.includes("~")); + assert.deepEqual(built.disclosedClaims, ["ageOver18", "country"]); + }); +}); diff --git a/packages/ows-types/tsconfig.json b/packages/ows-types/tsconfig.json new file mode 100644 index 0000000..a013e0c --- /dev/null +++ b/packages/ows-types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/ows-wallet-utils/CHANGELOG.md b/packages/ows-wallet-utils/CHANGELOG.md new file mode 100644 index 0000000..0f7a3e4 --- /dev/null +++ b/packages/ows-wallet-utils/CHANGELOG.md @@ -0,0 +1,149 @@ +# @1shotapi/ows-wallet-utils + +## 0.7.0 + +### Minor Changes + +- **ows-wallet-utils:** Publish Branding-side `wallet.solana.register()` (Solana Phase 1 wire). Local `0.6.0` already had this API, but npm `@1shotapi/ows-wallet-utils@0.6.0` was released earlier without Solana — consumers need a new minor so Docker/npm installs get `OWSWallet.solana`. + +## 0.6.0 + +### Minor Changes + +- 972fd3b: Solana Phase 1: CAIP-2 `SolanaChainId` (`solana:mainnet` / `devnet` / `testnet`), branded program/mint/signature primitives, `SolanaSigner.signMessage` / `signTransaction` + Kit partial signer, and Host/Branding `solana.*` wire methods (`proxy.solana` / `wallet.solana.register`). + +### Patch Changes + +- Updated dependencies [972fd3b] + - @1shotapi/ows-types@0.14.0 + +## 0.5.3 + +### Patch Changes + +- 6cd2357: Add `ChainUtils.asEVMChainId` (throws `OwsInvalidParamsError`) to normalize EIP-155 chain ids from number/string/bigint (with trim), and use it from signer/wallet EIP-1193 helpers instead of local `normalizeChainId` wrappers. Add `IEVMTransactionRequestSchema` and parse `eth_sendTransaction` params with Zod. +- Updated dependencies [6cd2357] + - @1shotapi/ows-types@0.12.0 + +## 0.5.2 + +### Patch Changes + +- 61ea18f: Fix flyout retention after personal_sign / typed-data (including SIWE): run optional `onAuthenticated` after display release, and harden `DisplayChildClient.releaseSession` so stale display IDs still decrement the live session instead of silently no-oping. +- 61ea18f: Add Zod 4 schemas on branded primitives (and `EChainTechnology`), with viem checksum / `@scure/base` address checks. Split AES-256 into `AES256CipherText` (raw hex) and `AES256CipherTextEnvelope` (`ows-aes1:…`). Wallet-utils EIP-1193 and credential composites now import the shared primitive schemas. +- Updated dependencies [61ea18f] + - @1shotapi/ows-types@0.11.0 + +## 0.5.1 + +### Patch Changes + +- 042beab: Bitcoin chain ids are now descriptive string sentinels (`"Bitcoin"`, `"BitcoinTestnet"`) instead of numeric `-1`/`-2`. Add `ChainUtils` for narrowing `OWSChainId` across EVM, Bitcoin, and Solana, plus `SolanaChainId` sentinels. +- Updated dependencies [042beab] + - @1shotapi/ows-types@0.8.0 + +## 0.5.0 + +### Minor Changes + +- 728eb8d: Add Bitcoin Native SegWit (P2WPKH) support: + - `ows-types`: Replace `BitcoinAccountAddress` with `BitcoinSegwitAccountAddress`. Add `BitcoinSignatureHex`, `BitcoinTransactionHash`, `BitcoinChainId` (-1 mainnet, -2 testnet), `OWSChainId`, BIP-122 static wallet types, and dual-network Bitcoin cache methods on `IOWSSigner`. + - `ows-signer-utils`: Add `BitcoinSigner` (`signer.bitcoin`), SegWit address derivation (`addressFromSecp256k1PublicKey`), BIP-143 transaction marshalling, and dual-network caching. + - `ows-provider`: Add `BitcoinHostClient` (`proxy.bitcoin.getAccountAddresses`) conforming to Reown BIP-122 static-wallet pattern. + - `ows-wallet-utils`: Add `validateBitcoinSegwitAddress` with strict bech32 SegWit v0 validation, and `BitcoinWalletRegistrar` (`wallet.bitcoin.register`). + +### Patch Changes + +- Updated dependencies [728eb8d] + - @1shotapi/ows-types@0.7.0 + +## 0.4.1 + +### Patch Changes + +- 36fa253: Wire Branding→Host EIP-1193 notifications (`chainChanged`, `accountsChanged`) over Postmate so hosts stay in sync when the wallet changes chain or accounts. +- Updated dependencies [36fa253] + - @1shotapi/ows-types@0.5.1 + +## 0.4.0 + +### Minor Changes + +- 09afc87: Add Branding→Host analytics wire (`ows:analytics`): `IOWSAnalyticsEvent` / abstract `OWSAnalyticsEvent` (branding subclasses) with index-signature extras, `OWSWallet.analytics.emit`, and `OWSProxy.analytics` EventEmitter. + +### Patch Changes + +- Updated dependencies [09afc87] + - @1shotapi/ows-types@0.4.0 + +## 0.3.0 + +### Minor Changes + +- ee8dcf7: Add EIP-7715 execution-permission types and optional RpcHelper hooks + + ows-types exports PermissionRequest/Response shapes and the four draft method names on the EIP-1193 method table. RpcHelper registers those methods when branding passes `executionPermissions` callbacks; UI remains app-owned. + +### Patch Changes + +- Updated dependencies [ee8dcf7] + - @1shotapi/ows-types@0.3.0 + +## 0.2.0 + +### Minor Changes + +- 2f6bb67: Host-owned wallet sizing: flyout vs full-screen drawer, no branding size requests. + + `requestDisplay` is a show session only (no width/height). On small viewports the host panel opens as a full-screen drawer with a bottom wipe; larger viewports keep the lower-right flyout at `walletSizeX`/`walletSizeY`. + +### Patch Changes + +- Updated dependencies [2f6bb67] + - @1shotapi/ows-types@0.2.4 + +## 0.1.2 + +### Patch Changes + +- 498d823: Add EChainTechnology and RelayerTransactionId; move IBlockchainProvider and AddressUtils (ENS-aware) into ows-wallet-utils with viem as a peer dependency. +- Updated dependencies [498d823] + - @1shotapi/ows-types@0.1.3 + +## 0.1.1 + +### Patch Changes + +- Add typed eth_sendTransaction support: branded transaction hash, RpcHelper active-chain request/broadcast helpers, and SignHelper prepare → sign → broadcast with transaction consent. +- Updated dependencies + - @1shotapi/ows-types@0.1.2 + +## 0.1.0 + +### Minor Changes + +- 0e1b1d1: Move SD-JWT VC presentation and crypto utilities from `ows-wallet-utils` into `ows-types` (`utils/credentials/`). Drop `ows-provider`'s dependency on `ows-wallet-utils`; host apps use `@1shotapi/ows-types` for presentation helpers and `@1shotapi/ows-provider` for verify/proxy only. + + **Breaking:** `buildSdJwtVcPresentation`, `extractHolderJwkFromSdJwtVc`, and crypto helpers are no longer exported from `@1shotapi/ows-wallet-utils` or re-exported from `@1shotapi/ows-provider`. Use `PresentationUtils` / `CredentialCryptoUtils` from `@1shotapi/ows-types`. `CredentialCryptoUtils.createOwsEd25519HolderSigner` lives on `@1shotapi/ows-types`. + +- aaf43d4: Add `OWSWallet.requestDisplay()` / `requestHide()` so the branding iframe can ask the host to show or hide a lower-right wallet flyout (opaque panel, no modal backdrop) before WebAuthn or approval UI in cross-origin embeds. Host-side `OWSProxy` handles `ows:requestDisplay`, `ows:requestHide`, and `ows:releaseDisplay`. +- e83e5ce: Fold verifiable credentials into core OWS packages: types and OID4 interfaces in `ows-types`, branding wire and SD-JWT presentation in `ows-wallet-utils`, host proxy and SD-JWT verify in `ows-provider`. Demo mocks live under `examples/shared` (not published). Removes `@1shotapi/ows-credentials`. +- 89e40a9: Phase 3 credentials hardening: `ICredentialRepository`, `IIssuerTrustRegistry.isTrustedIssuer`, `CredentialsHelper`, host `acceptedIssuers` on present, fail-closed status checks, and stubbed `OWSSigner.encryptAES256` / `decryptAES256` with branded `AES256CipherText`. +- a78bba8: Phase 4 credentials: optional `@1shotapi/ows-oid4` add-on (`CredentialsHelper`, HTTP OID4VCI/OID4VP clients). Holder bridge is `CredentialCryptoUtils.createOwsEd25519HolderSigner` in `ows-types`. Wire registrar/Zod schemas stay in `ows-wallet-utils`. `IOWSSigner` lives in `ows-types` (implemented by `OWSSigner`). Verifier/issuer HTTP demos own their server-side JWE helpers. +- 9a87faf: Move shared EIP-1193 method lists and typed request tables into `@1shotapi/ows-types`. Add `RpcHelper` in `ows-wallet-utils` to register JSON-RPC read methods and chain switching on `OWSWallet` (replaces the registry rpc-provider module for branding apps). +- eae834a: Add `EVMChainId` and allow branding modules to register additional EIP-1193 methods (e.g. JSON-RPC reads) on the wallet Postmate model. + +### Patch Changes + +- Updated dependencies [0e1b1d1] +- Updated dependencies [aaf43d4] +- Updated dependencies [e83e5ce] +- Updated dependencies [3c8b506] +- Updated dependencies [89e40a9] +- Updated dependencies [a78bba8] +- Updated dependencies [9a87faf] +- Updated dependencies [eae834a] +- Updated dependencies [55f9a22] +- Updated dependencies [aaf43d4] +- Updated dependencies [8eeecf2] + - @1shotapi/ows-types@0.1.0 diff --git a/packages/ows-wallet-utils/README.md b/packages/ows-wallet-utils/README.md new file mode 100644 index 0000000..7f5dd51 --- /dev/null +++ b/packages/ows-wallet-utils/README.md @@ -0,0 +1,118 @@ +# @1shotapi/ows-wallet-utils + +Utilities for **Branding Layer** implementers to communicate with the **Host Layer**. + +Built on [postmate](https://github.com/dollarshaveclub/postmate) with a JSON-RPC-style callback protocol and Zod-validated EIP-1193 params. + +## Install + +```bash +npm install @1shotapi/ows-wallet-utils zod +``` + +## Quick start (Branding Layer) + +```typescript +import { OWSWallet } from "@1shotapi/ows-wallet-utils"; +import { z } from "zod"; + +const wallet = await OWSWallet.create({ + eip1193: { + async eth_requestAccounts() { + return ["0xYourAddress"]; + }, + async personal_sign(params) { + const [message] = params as [string, string]; + return "0x…"; + }, + }, + rpc: { + getStatus: { + handler: async () => ({ connected: true }), + }, + }, +}); + +// Or register before handshake: +const prepared = OWSWallet.prepare(); +prepared.registerRpc( + "customMethod", + async (params) => ({ ok: true }), + z.object({ foo: z.number() }), +); +await prepared.start(); +``` + +Unregistered EIP-1193 methods respond with `OwsUnimplementedError` (`-32601`). Custom RPC methods must be registered via `options.rpc` or `registerRpc()` before `start()`. + +### Analytics (Branding → Host) + +Emit product events to the host over Postmate (`ows:analytics`). Extend `OWSAnalyticsEvent` so `eventId`, `timestamp`, and branded `hostDomain` are filled in `super(...)`; add branding fields on the subclass. + +```typescript +import { + DomainString, + EVMAccountAddress, + OWSAnalyticsEvent, +} from "@1shotapi/ows-types"; +import { OWSWallet } from "@1shotapi/ows-wallet-utils"; + +class AccountCreatedEvent extends OWSAnalyticsEvent { + constructor( + hostDomain: DomainString, + public readonly accountAddress: EVMAccountAddress, + ) { + super("AccountCreated", hostDomain); + } +} + +const wallet = await OWSWallet.create({ /* … */ }); +wallet.analytics.emit( + new AccountCreatedEvent(DomainString("app.example.com"), EVMAccountAddress("0x…")), +); +``` + +### Debug logging + +Enable `console.debug` traces for Postmate handshake and RPC traffic: + +```typescript +await OWSWallet.create({ debug: true, eip1193: { ... } }); +``` + +Or in the browser console before the wallet loads: + +```javascript +localStorage.setItem("ows-wallet-utils:debug", "1"); +// or: globalThis.OWS_WALLET_UTILS_DEBUG = true; +``` + +## Protocol + +- Host calls `child.call(method, envelope)` via Postmate +- Child emits `ows:rpcCallback` with `{ callId, success, result | error }` +- Host-side `OWSProxy` sets iframe `allow` (WebAuthn, clipboard) before navigation — do not override after load + +## Exports + +- `OWSWallet` — child-side Postmate model +- `AnalyticsChildClient` — Branding→Host analytics emit (`wallet.analytics.emit`) +- `RpcHelper` — EIP-1193 read methods + chain switching against JSON-RPC URLs +- `EIP1193_PARAM_SCHEMAS`, `getEip1193ParamSchema` — Zod validators for standard methods +- `CredentialWalletRegistrar`, `CREDENTIAL_PARAM_SCHEMAS` — credentials wire hooks (handlers still registered on `OWSWallet.credentials`) + +For OID4 issuance/presentation orchestration, install the optional `@1shotapi/ows-oid4` add-on (`CredentialsHelper`, HTTP clients). + +Shared protocol types and errors live in `@1shotapi/ows-types`. + +## Related + +| Package | Role | +|---------|------| +| `@1shotapi/ows-provider` | Host Layer `OWSProxy` + EIP-1193 | +| `@1shotapi/ows-signer-utils` | Branding Layer ↔ Signing Layer | +| `@1shotapi/ows-oid4` | Optional credentials add-on | + +## License + +MIT diff --git a/packages/ows-wallet-utils/package.json b/packages/ows-wallet-utils/package.json new file mode 100644 index 0000000..e2b3c1f --- /dev/null +++ b/packages/ows-wallet-utils/package.json @@ -0,0 +1,54 @@ +{ + "name": "@1shotapi/ows-wallet-utils", + "version": "0.7.0", + "description": "OWS utilities for Branding Layer implementers — Host Layer ↔ Branding Layer messaging via postmate", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "node --import tsx --test test/*.test.ts", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@1shotapi/ows-types": "*", + "@scure/base": "^2.4.0", + "postmate": "^1.5.2", + "zod": "^4.6.5" + }, + "peerDependencies": { + "viem": "^2.0.0" + }, + "devDependencies": { + "@types/postmate": "^1.5.4", + "tsx": "^4.19.0", + "viem": "^2.52.0" + }, + "keywords": [ + "ows", + "open-wallet-standard", + "postmate", + "iframe", + "wallet" + ], + "repository": { + "type": "git", + "url": "https://github.com/1Shot-API/open-wallet.git", + "directory": "packages/ows-wallet-utils" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ows-wallet-utils/src/AddressUtils.ts b/packages/ows-wallet-utils/src/AddressUtils.ts new file mode 100644 index 0000000..1d8b1d3 --- /dev/null +++ b/packages/ows-wallet-utils/src/AddressUtils.ts @@ -0,0 +1,113 @@ +import { getAddress } from "viem"; +import { getEnsAddress } from "viem/actions"; +import { + BITCOIN_MAINNET_CHAIN_ID, + type BitcoinChainId, + BitcoinSegwitAccountAddress, + EVMAccountAddress, + type EVMChainId, + SolanaAccountAddress, +} from "@1shotapi/ows-types"; +import { bech32 } from "@scure/base"; +import type { IBlockchainProvider } from "./IBlockchainProvider.js"; + +const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; +const EVM_ADDRESS_BARE_RE = /^[0-9a-fA-F]{40}$/; +/** Solana base58 alphabet (no 0, O, I, l). */ +const SOLANA_BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + +/** + * Validates and brands account addresses. EVM paths may resolve ENS via the + * injected {@link IBlockchainProvider}. + */ +export class AddressUtils { + constructor(private readonly blockchain: IBlockchainProvider) {} + + /** + * Normalize a hex EVM address (EIP-55 checksum) or resolve an ENS name on + * `chainId` (also returned checksummed). + */ + async validateEVMAddress( + input: string, + chainId: EVMChainId, + ): Promise { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Empty address"); + } + + if (trimmed.includes(".")) { + const client = this.blockchain.getPublicClient(chainId); + const resolved = await getEnsAddress(client, { name: trimmed }); + if (!resolved) { + throw new Error(`Unable to resolve ENS name: ${trimmed}`); + } + return EVMAccountAddress(getAddress(resolved)); + } + + let hex = trimmed; + if (EVM_ADDRESS_BARE_RE.test(hex)) { + hex = `0x${hex}`; + } + if (!EVM_ADDRESS_RE.test(hex)) { + throw new Error(`Invalid EVM address: ${input}`); + } + try { + return EVMAccountAddress(getAddress(hex as `0x${string}`)); + } catch { + throw new Error(`Invalid EVM address: ${input}`); + } + } + + /** Validate a Solana base58 address. */ + validateSolanaAddress(input: string): SolanaAccountAddress { + const trimmed = input.trim(); + if (!SOLANA_BASE58_RE.test(trimmed)) { + throw new Error(`Invalid Solana address: ${input}`); + } + return SolanaAccountAddress(trimmed); + } + + /** + * Validate a Bitcoin Native SegWit (P2WPKH) address: + * bech32-decoded witness version 0, 20-byte witness program, and matching HRP. + * Rejects non-Segwit formats (hex, legacy base58, and Taproot bc1p/tb1p). + */ + validateBitcoinSegwitAddress( + input: string, + chainId: BitcoinChainId = BITCOIN_MAINNET_CHAIN_ID, + ): BitcoinSegwitAccountAddress { + const trimmed = input.trim().toLowerCase(); + const expectedHrp = chainId === BITCOIN_MAINNET_CHAIN_ID ? "bc" : "tb"; + + try { + const decoded = bech32.decode(trimmed); + if (decoded.prefix !== expectedHrp) { + throw new Error( + `Bitcoin address HRP "${decoded.prefix}" does not match expected "${expectedHrp}" for chain ${chainId}`, + ); + } + + // Witness version 0 (P2WPKH) + const version = decoded.words[0]; + if (version !== 0) { + throw new Error( + `Expected witness version 0 for P2WPKH SegWit address, got version ${version}`, + ); + } + + // Program bytes (words after version converted from 5-bit to 8-bit) + const program = bech32.fromWords(decoded.words.slice(1)); + if (program.length !== 20) { + throw new Error( + `Expected 20-byte witness program for P2WPKH SegWit address, got ${program.length} bytes`, + ); + } + + return BitcoinSegwitAccountAddress(trimmed); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid Bitcoin SegWit address: ${input} (${msg})`); + } + } +} diff --git a/packages/ows-wallet-utils/src/IBlockchainProvider.ts b/packages/ows-wallet-utils/src/IBlockchainProvider.ts new file mode 100644 index 0000000..9417a25 --- /dev/null +++ b/packages/ows-wallet-utils/src/IBlockchainProvider.ts @@ -0,0 +1,12 @@ +import type { PublicClient } from "viem"; +import type { EVMChainId } from "@1shotapi/ows-types"; + +/** + * Provides read-only chain RPC clients (balances, ENS, eth_sendRawTransaction interim). + */ +export interface IBlockchainProvider { + /** Returns a viem PublicClient for the chain, or throws if unsupported. */ + getPublicClient(chainId: EVMChainId): PublicClient; +} + +export const IBlockchainProviderType = Symbol.for("IBlockchainProvider"); diff --git a/packages/ows-wallet-utils/src/analytics/child-client.ts b/packages/ows-wallet-utils/src/analytics/child-client.ts new file mode 100644 index 0000000..697c0e6 --- /dev/null +++ b/packages/ows-wallet-utils/src/analytics/child-client.ts @@ -0,0 +1,30 @@ +import { + OWS_ANALYTICS_EVENT, + serializeRpc, + type IOWSAnalyticsEvent, +} from "@1shotapi/ows-types"; +import type Postmate from "postmate"; +import { debugLog } from "../debug.js"; + +/** + * Branding → Host analytics push (fire-and-forget Postmate emit). + * Failures are logged and never thrown into wallet UX. + */ +export class AnalyticsChildClient { + private readonly childApi: Postmate.ChildAPI; + + constructor(childApi: Postmate.ChildAPI) { + this.childApi = childApi; + } + + emit(event: IOWSAnalyticsEvent): void { + try { + this.childApi.emit(OWS_ANALYTICS_EVENT, serializeRpc(event)); + } catch (error) { + debugLog("analytics emit failed", { + name: event.name, + error: error instanceof Error ? error.message : error, + }); + } + } +} diff --git a/packages/ows-wallet-utils/src/bitcoin/wallet-registrar.ts b/packages/ows-wallet-utils/src/bitcoin/wallet-registrar.ts new file mode 100644 index 0000000..11240c6 --- /dev/null +++ b/packages/ows-wallet-utils/src/bitcoin/wallet-registrar.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { + BITCOIN_WIRE_METHODS, + type IOpenWalletBitcoinProvider, +} from "@1shotapi/ows-types"; + +export const getAccountAddressesParamsSchema = z + .object({ + account: z.string().optional(), + intentions: z.array(z.string()).optional(), + chainId: z + .enum(["Bitcoin", "BitcoinTestnet"]) + .optional(), + }) + .optional() + .default({}); + +export const BITCOIN_PARAM_SCHEMAS = { + getAccountAddresses: getAccountAddressesParamsSchema, +} as const; + +type RpcHandlerRegistration = { + handler: (params: unknown) => Promise; + paramsSchema?: z.ZodType; +}; + +export class BitcoinWalletRegistrar { + private readonly handlers = new Map(); + + register(provider: IOpenWalletBitcoinProvider): void { + this.handlers.set(BITCOIN_WIRE_METHODS.getAccountAddresses, { + handler: (params: unknown) => + provider.getAccountAddresses( + params as Parameters[0], + ), + paramsSchema: BITCOIN_PARAM_SCHEMAS.getAccountAddresses, + }); + } + + getRegistration(wireMethod: string): RpcHandlerRegistration | undefined { + return this.handlers.get(wireMethod); + } + + registeredWireMethods(): string[] { + return Array.from(this.handlers.keys()); + } +} diff --git a/packages/ows-wallet-utils/src/credentials/schemas.ts b/packages/ows-wallet-utils/src/credentials/schemas.ts new file mode 100644 index 0000000..9d80169 --- /dev/null +++ b/packages/ows-wallet-utils/src/credentials/schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { + CredentialClaimNameSchema, + CredentialConfigurationIdSchema, + CredentialIdSchema, + CredentialIssuerSchema, + CredentialOfferUriSchema, + CredentialTypeNameSchema, + PresentationRequestUriSchema, + UriStringSchema, +} from "@1shotapi/ows-types"; + +const credentialOfferSchema = z.object({ + credentialIssuer: CredentialIssuerSchema, + credentialConfigurationIds: z.array(CredentialConfigurationIdSchema), + grants: z.record(z.string(), z.unknown()).optional(), +}); + +export const acceptOfferParamsSchema = z + .object({ + credentialOfferUri: CredentialOfferUriSchema.optional(), + offer: credentialOfferSchema.optional(), + }) + .refine((v) => v.credentialOfferUri !== undefined || v.offer !== undefined, { + message: "credentialOfferUri or offer is required", + }); + +export const presentParamsSchema = z + .object({ + requestUri: PresentationRequestUriSchema.optional(), + request: z + .object({ + id: z.string(), + verifier: z.object({ + id: UriStringSchema, + name: z.string(), + }), + requestedClaims: z.array(CredentialClaimNameSchema), + credentialTypes: z.array(CredentialTypeNameSchema).optional(), + nonce: z.string().optional(), + audience: z.string().optional(), + }) + .optional(), + acceptedIssuers: z.array(CredentialIssuerSchema).optional(), + }) + .refine((v) => v.requestUri !== undefined || v.request !== undefined, { + message: "requestUri or request is required", + }); + +export const listParamsSchema = z + .object({ + type: CredentialTypeNameSchema.optional(), + issuer: CredentialIssuerSchema.optional(), + }) + .optional(); + +export const deleteParamsSchema = z.object({ + credentialId: CredentialIdSchema, +}); + +export const CREDENTIAL_PARAM_SCHEMAS = { + acceptOffer: acceptOfferParamsSchema, + present: presentParamsSchema, + list: listParamsSchema, + delete: deleteParamsSchema, +} as const; diff --git a/packages/ows-wallet-utils/src/credentials/wallet-registrar.ts b/packages/ows-wallet-utils/src/credentials/wallet-registrar.ts new file mode 100644 index 0000000..fe04a95 --- /dev/null +++ b/packages/ows-wallet-utils/src/credentials/wallet-registrar.ts @@ -0,0 +1,63 @@ +import type { z } from "zod"; +import { + CREDENTIAL_WIRE_METHODS, + type OpenWalletCredentialProvider, +} from "@1shotapi/ows-types"; +import { CREDENTIAL_PARAM_SCHEMAS } from "./schemas.js"; + +type RpcHandlerRegistration = { + handler: (params: unknown) => Promise; + paramsSchema?: z.ZodType; +}; + +type CredentialMethod = keyof typeof CREDENTIAL_WIRE_METHODS; + +const METHOD_TO_WIRE: Record = { + acceptOffer: CREDENTIAL_WIRE_METHODS.acceptOffer, + present: CREDENTIAL_WIRE_METHODS.present, + list: CREDENTIAL_WIRE_METHODS.list, + delete: CREDENTIAL_WIRE_METHODS.delete, +}; + +const PARAM_SCHEMAS: Record = { + acceptOffer: CREDENTIAL_PARAM_SCHEMAS.acceptOffer, + present: CREDENTIAL_PARAM_SCHEMAS.present, + list: CREDENTIAL_PARAM_SCHEMAS.list, + delete: CREDENTIAL_PARAM_SCHEMAS.delete, +}; + +export class CredentialWalletRegistrar { + private readonly handlers = new Map(); + + register(handlers: OpenWalletCredentialProvider): void { + const entries: Array< + [CredentialMethod, OpenWalletCredentialProvider[CredentialMethod]] + > = + [ + ["acceptOffer", handlers.acceptOffer], + ["present", handlers.present], + ["list", handlers.list], + ["delete", handlers.delete], + ]; + + for (const [method, handler] of entries) { + const wireKey = METHOD_TO_WIRE[method]; + this.handlers.set(wireKey, { + paramsSchema: PARAM_SCHEMAS[method], + handler: async (params) => handler(params as never), + }); + } + } + + getWireMethods(): string[] { + return [...this.handlers.keys()]; + } + + getRegistration(wireKey: string): RpcHandlerRegistration | undefined { + return this.handlers.get(wireKey); + } + + isRegistered(): boolean { + return this.handlers.size > 0; + } +} diff --git a/packages/ows-wallet-utils/src/debug.ts b/packages/ows-wallet-utils/src/debug.ts new file mode 100644 index 0000000..2b47a1b --- /dev/null +++ b/packages/ows-wallet-utils/src/debug.ts @@ -0,0 +1,39 @@ +const DEBUG_STORAGE_KEY = "ows-wallet-utils:debug"; + +let optionsDebug = false; + +/** Enable via `OWSWalletOptions.debug`, `localStorage['ows-wallet-utils:debug']='1'`, or `globalThis.OWS_WALLET_UTILS_DEBUG`. */ +export function setOwsWalletDebugFromOptions(enabled: boolean | undefined): void { + optionsDebug = enabled === true; +} + +export function isOwsWalletDebugEnabled(): boolean { + if (optionsDebug) { + return true; + } + if ( + typeof globalThis !== "undefined" && + (globalThis as { OWS_WALLET_UTILS_DEBUG?: boolean }).OWS_WALLET_UTILS_DEBUG === + true + ) { + return true; + } + if ( + typeof localStorage !== "undefined" && + localStorage.getItem(DEBUG_STORAGE_KEY) === "1" + ) { + return true; + } + return false; +} + +export function debugLog(message: string, data?: unknown): void { + if (!isOwsWalletDebugEnabled()) { + return; + } + if (data === undefined) { + console.debug(`[ows-wallet-utils] ${message}`); + return; + } + console.debug(`[ows-wallet-utils] ${message}`, data); +} diff --git a/packages/ows-wallet-utils/src/display/child-client.ts b/packages/ows-wallet-utils/src/display/child-client.ts new file mode 100644 index 0000000..f258e3e --- /dev/null +++ b/packages/ows-wallet-utils/src/display/child-client.ts @@ -0,0 +1,237 @@ +import { + DEFAULT_DISPLAY_TIMEOUT_MS, + DisplayRequestId, + OWS_DISPLAY_READY_MODEL_METHOD, + OWS_HIDE_READY_MODEL_METHOD, + OWS_RELEASE_DISPLAY_EVENT, + OWS_REQUEST_DISPLAY_EVENT, + OWS_REQUEST_HIDE_EVENT, + deserializeDisplayReady, + deserializeHideReady, + serializeRpc, + type DisplayReadyPayload, + type HideReadyPayload, + type RequestDisplayParams, +} from "@1shotapi/ows-types"; +import type Postmate from "postmate"; +import { debugLog } from "../debug.js"; + +export type DisplaySession = { + displayId: DisplayRequestId; + release(): void; + hide(): Promise; +}; + +type PendingDisplay = { + resolve: (session: DisplaySession) => void; + reject: (error: Error) => void; + timeoutId: ReturnType; +}; + +type PendingHide = { + resolve: () => void; + reject: (error: Error) => void; + timeoutId: ReturnType; +}; + +let nextDisplayId = 1; + +export class DisplayChildClient { + private readonly pending = new Map(); + private readonly pendingHide = new Map(); + private readonly childApi: Postmate.ChildAPI; + private activeSession: DisplaySession | null = null; + private displayDepth = 0; + + constructor(childApi: Postmate.ChildAPI) { + this.childApi = childApi; + } + + handleDisplayReady(data: unknown): void { + let payload: DisplayReadyPayload; + try { + payload = deserializeDisplayReady(data); + } catch (error) { + debugLog("display ready deserialize failed", { + error: error instanceof Error ? error.message : error, + }); + return; + } + + const pending = this.pending.get(payload.displayId); + if (!pending) { + debugLog("display ready for unknown id", { displayId: payload.displayId }); + return; + } + + clearTimeout(pending.timeoutId); + this.pending.delete(payload.displayId); + + const userActivationActive = + typeof navigator !== "undefined" && + "userActivation" in navigator && + navigator.userActivation.isActive; + + debugLog("display ready", { + displayId: payload.displayId, + userActivationActive, + }); + + const session: DisplaySession = { + displayId: payload.displayId, + release: () => this.releaseSession(payload.displayId), + hide: () => this.requestHide(payload.displayId), + }; + // Parallel requestDisplay races can acknowledge out of order — keep depth + // instead of resetting to 1 when overwriting the active session. + if (this.activeSession) { + this.displayDepth++; + } else { + this.displayDepth = 1; + } + this.activeSession = session; + pending.resolve(session); + } + + handleHideReady(data: unknown): void { + let payload: HideReadyPayload; + try { + payload = deserializeHideReady(data); + } catch (error) { + debugLog("hide ready deserialize failed", { + error: error instanceof Error ? error.message : error, + }); + return; + } + + const key = payload.displayId ?? "anonymous"; + const pending = this.pendingHide.get(key); + if (!pending) { + debugLog("hide ready for unknown id", { displayId: payload.displayId }); + return; + } + + clearTimeout(pending.timeoutId); + this.pendingHide.delete(key); + pending.resolve(); + } + + requestDisplay( + _params: RequestDisplayParams = {}, + timeoutMs = DEFAULT_DISPLAY_TIMEOUT_MS, + ): Promise { + if (this.activeSession) { + this.displayDepth++; + debugLog("display session reused", { + displayId: this.activeSession.displayId, + depth: this.displayDepth, + }); + return Promise.resolve(this.activeSession); + } + + const displayId = DisplayRequestId(String(nextDisplayId++)); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pending.delete(displayId); + reject(new Error("Display request timed out")); + }, timeoutMs); + + this.pending.set(displayId, { resolve, reject, timeoutId }); + + debugLog("requesting display from host", { displayId }); + this.childApi.emit( + OWS_REQUEST_DISPLAY_EVENT, + serializeRpc({ displayId }), + ); + }); + } + + requestHide( + displayId?: DisplayRequestId, + timeoutMs = DEFAULT_DISPLAY_TIMEOUT_MS, + ): Promise { + const key = displayId ?? "anonymous"; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingHide.delete(key); + reject(new Error("Hide request timed out")); + }, timeoutMs); + + this.pendingHide.set(key, { resolve, reject, timeoutId }); + + debugLog("requesting hide from host", { displayId }); + this.childApi.emit( + OWS_REQUEST_HIDE_EVENT, + serializeRpc( + displayId ? { displayId } : {}, + ), + ); + }).finally(() => { + this.clearActiveSession(displayId); + }); + } + + destroy(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + pending.reject(new Error("DisplayChildClient destroyed")); + } + for (const pending of this.pendingHide.values()) { + clearTimeout(pending.timeoutId); + pending.reject(new Error("DisplayChildClient destroyed")); + } + this.pending.clear(); + this.pendingHide.clear(); + this.activeSession = null; + this.displayDepth = 0; + } + + private releaseSession(displayId: DisplayRequestId): void { + if (!this.activeSession) { + return; + } + + // Parallel displayReady can overwrite activeSession with a newer id while + // callers still hold the older handle. Always decrement/release against the + // live session so a stale release cannot silently no-op and leak childDisplayId. + const activeId = this.activeSession.displayId; + if (activeId !== displayId) { + debugLog("release with stale displayId; releasing active session", { + staleId: displayId, + activeId, + depth: this.displayDepth, + }); + } + + this.displayDepth = Math.max(0, this.displayDepth - 1); + if (this.displayDepth > 0) { + debugLog("display session retained", { + displayId: activeId, + depth: this.displayDepth, + }); + return; + } + + debugLog("releasing display", { displayId: activeId }); + this.childApi.emit( + OWS_RELEASE_DISPLAY_EVENT, + serializeRpc({ displayId: activeId } satisfies { + displayId: DisplayRequestId; + }), + ); + this.clearActiveSession(activeId); + } + + private clearActiveSession(displayId?: DisplayRequestId): void { + if (displayId && this.activeSession?.displayId !== displayId) { + return; + } + this.activeSession = null; + this.displayDepth = 0; + } +} + +export const DISPLAY_READY_MODEL_METHOD = OWS_DISPLAY_READY_MODEL_METHOD; +export const HIDE_READY_MODEL_METHOD = OWS_HIDE_READY_MODEL_METHOD; diff --git a/packages/ows-wallet-utils/src/eip1193/event-child-client.ts b/packages/ows-wallet-utils/src/eip1193/event-child-client.ts new file mode 100644 index 0000000..9ba4ce2 --- /dev/null +++ b/packages/ows-wallet-utils/src/eip1193/event-child-client.ts @@ -0,0 +1,31 @@ +import { + OWS_EIP1193_EVENT, + serializeRpc, + type IOWSEip1193EventNotification, +} from "@1shotapi/ows-types"; +import type Postmate from "postmate"; +import { debugLog } from "../debug.js"; + +/** + * Branding → Host EIP-1193 notifications (fire-and-forget Postmate emit). + * Failures are logged and never thrown into wallet UX. + */ +export class Eip1193EventChildClient { + private readonly childApi: Postmate.ChildAPI; + + constructor(childApi: Postmate.ChildAPI) { + this.childApi = childApi; + } + + emit(event: string, ...params: unknown[]): void { + const payload: IOWSEip1193EventNotification = { event, params }; + try { + this.childApi.emit(OWS_EIP1193_EVENT, serializeRpc(payload)); + } catch (error) { + debugLog("eip1193 event emit failed", { + event, + error: error instanceof Error ? error.message : error, + }); + } + } +} diff --git a/packages/ows-wallet-utils/src/eip1193/rpc-helper.ts b/packages/ows-wallet-utils/src/eip1193/rpc-helper.ts new file mode 100644 index 0000000..949ef16 --- /dev/null +++ b/packages/ows-wallet-utils/src/eip1193/rpc-helper.ts @@ -0,0 +1,412 @@ +import { + ChainUtils, + EIP1193_READ_METHODS, + EIP1193_UNRECOGNIZED_CHAIN_ID, + EVMChainId, + EVMTransactionHash, + HexString, + OwsInvalidParamsError, + OwsRpcError, + type IExecutionPermissionRequest, + type IExecutionPermissionResponse, + type IRevokeExecutionPermissionParams, + type SupportedExecutionPermissions, +} from "@1shotapi/ows-types"; +import type { Eip1193Handler } from "../ows-wallet.js"; + +export type RpcHelperEventMap = { + chainChanged: [chainId: EVMChainId]; +}; + +type Listener = ( + ...args: RpcHelperEventMap[E] +) => void; + +/** Minimal EventEmitter for RpcHelper (no Node `events` dependency). */ +export class RpcHelperEventEmitter { + private readonly listeners = new Map< + keyof RpcHelperEventMap, + Set> + >(); + + on( + event: E, + listener: Listener, + ): this { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(listener as Listener); + return this; + } + + off( + event: E, + listener: Listener, + ): this { + this.listeners + .get(event) + ?.delete(listener as Listener); + return this; + } + + emit( + event: E, + ...args: RpcHelperEventMap[E] + ): boolean { + const set = this.listeners.get(event); + if (!set || set.size === 0) { + return false; + } + for (const listener of set) { + (listener as Listener)(...args); + } + return true; + } +} + +/** Wallet surface required to register EIP-1193 handlers. */ +export type RpcHelperWallet = { + registerEip1193(method: string, handler: Eip1193Handler): void; +}; + +/** + * Optional Signing Layer handle for future signing/broadcast helpers. + * Typed loosely so `ows-wallet-utils` does not depend on `ows-signer-utils`. + * Pass an `OWSSigner` instance from the branding app when available. + */ +export type RpcHelperSigner = object; + +/** + * Branding callbacks for EIP-7715 execution permissions. + * When provided on {@link RpcHelperOptions}, RpcHelper registers the matching + * EIP-1193 methods. UI / MetaMask Delegation Framework logic stays app-owned — + * see the 1Shot embedded-wallet repo for a reference implementation. + */ +export type RpcHelperExecutionPermissionsHooks = { + requestExecutionPermissions: ( + requests: readonly IExecutionPermissionRequest[], + ) => Promise; + revokeExecutionPermission: ( + params: IRevokeExecutionPermissionParams, + ) => Promise; + getSupportedExecutionPermissions: () => Promise; + getGrantedExecutionPermissions: () => Promise; +}; + +export type RpcHelperOptions = { + /** + * Active chain on construct. Defaults to the first entry in `providers` + * (insertion order). + */ + defaultChainId?: EVMChainId; + /** + * Called before switching chains. Return `false` to abort the switch + * (no error; handler resolves `null` and chain is unchanged). + */ + beforeSwitchChain?: ( + chainId: EVMChainId, + ) => boolean | void | Promise; + /** Fired after the active chain id changes. */ + onChainChanged?: (chainId: EVMChainId) => void; + /** + * Optional EIP-7715 hooks. When set, registers + * `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission`, + * `wallet_getSupportedExecutionPermissions`, and + * `wallet_getGrantedExecutionPermissions`. Omit to leave those methods + * unimplemented on the wallet. + */ + executionPermissions?: RpcHelperExecutionPermissionsHooks; +}; + +/** + * Registers EIP-1193 read methods and chain selection on an `OWSWallet`. + * + * Call after `OWSWallet.prepare()` and before `wallet.start()`. + * + * @param providers - Chain id → JSON-RPC HTTP(S) URL + * @param wallet - Branding wallet used to `registerEip1193` + * @param signer - Optional `OWSSigner` (reserved for future helpers) + * @param options - Default chain and branding hooks + */ +export class RpcHelper { + readonly events = new RpcHelperEventEmitter(); + + private readonly providers: Map; + private readonly beforeSwitchChain?: RpcHelperOptions["beforeSwitchChain"]; + private currentChainId: EVMChainId; + + constructor( + providers: Map | ReadonlyMap, + wallet: RpcHelperWallet, + _signer?: RpcHelperSigner | null, + options: RpcHelperOptions = {}, + ) { + this.providers = normalizeProviders(providers); + if (this.providers.size === 0) { + throw new Error("RpcHelper: providers map must not be empty"); + } + + this.beforeSwitchChain = options.beforeSwitchChain; + this.currentChainId = resolveDefaultChainId( + this.providers, + options.defaultChainId, + ); + + if (options.onChainChanged) { + this.events.on("chainChanged", options.onChainChanged); + } + + wallet.registerEip1193("eth_chainId", async () => this.currentChainId); + + wallet.registerEip1193("wallet_switchEthereumChain", async (params) => + this.switchChain(readSwitchChainId(params)), + ); + + const proxy = + (method: string): Eip1193Handler => + async (params) => { + const url = this.providers.get(this.currentChainId); + if (!url) { + throw new OwsRpcError( + `No RPC provider configured for chain ${this.currentChainId}`, + EIP1193_UNRECOGNIZED_CHAIN_ID, + { chainId: this.currentChainId }, + ); + } + return jsonRpcRequest(url, method, params); + }; + + for (const method of EIP1193_READ_METHODS) { + wallet.registerEip1193(method, proxy(method)); + } + + this.registerExecutionPermissions(wallet, options.executionPermissions); + } + + getChainId(): EVMChainId { + return this.currentChainId; + } + + getConfiguredChainIds(): readonly EVMChainId[] { + return [...this.providers.keys()]; + } + + /** + * JSON-RPC against the active chain's configured provider. + * Used by Branding Layer helpers (e.g. transaction prepare / broadcast). + * Does not register Host-facing methods such as `eth_sendRawTransaction`. + */ + async request(method: string, params: unknown[] = []): Promise { + const url = this.providers.get(this.currentChainId); + if (!url) { + throw new OwsRpcError( + `No RPC provider configured for chain ${this.currentChainId}`, + EIP1193_UNRECOGNIZED_CHAIN_ID, + { chainId: this.currentChainId }, + ); + } + return jsonRpcRequest(url, method, params); + } + + /** + * Broadcast a signed raw transaction on the active chain. + * Intended for SignHelper / branding internals — not Host EIP-1193. + */ + async sendRawTransaction( + signedTransaction: HexString | `0x${string}`, + ): Promise { + const result = await this.request("eth_sendRawTransaction", [ + signedTransaction, + ]); + if ( + typeof result !== "string" || + !/^0x[0-9a-fA-F]{64}$/.test(result) + ) { + throw new OwsRpcError( + "eth_sendRawTransaction returned an invalid transaction hash", + -32_603, + { result }, + ); + } + return EVMTransactionHash(result as `0x${string}`); + } + + /** Same logic as the EIP-1193 `wallet_switchEthereumChain` handler. */ + async switchChain(chainIdInput: EVMChainId | string): Promise { + const chainId = ChainUtils.asEVMChainId(chainIdInput); + if (!this.providers.has(chainId)) { + throw new OwsRpcError( + `Unrecognized chain ID: ${chainId}`, + EIP1193_UNRECOGNIZED_CHAIN_ID, + { chainId }, + ); + } + + if (this.beforeSwitchChain) { + const allowed = await this.beforeSwitchChain(chainId); + if (allowed === false) { + return null; + } + } + + if (chainId !== this.currentChainId) { + this.currentChainId = chainId; + this.events.emit("chainChanged", this.currentChainId); + } + return null; + } + + private registerExecutionPermissions( + wallet: RpcHelperWallet, + hooks: RpcHelperExecutionPermissionsHooks | undefined, + ): void { + if (!hooks) { + return; + } + + wallet.registerEip1193( + "wallet_requestExecutionPermissions", + async (params) => + hooks.requestExecutionPermissions( + params as unknown as readonly IExecutionPermissionRequest[], + ), + ); + + wallet.registerEip1193( + "wallet_revokeExecutionPermission", + async (params) => { + const entry = params[0]; + if ( + entry === null || + typeof entry !== "object" || + !("permissionContext" in entry) + ) { + throw new OwsInvalidParamsError( + "wallet_revokeExecutionPermission requires [{ permissionContext }]", + ); + } + return hooks.revokeExecutionPermission( + entry as IRevokeExecutionPermissionParams, + ); + }, + ); + + wallet.registerEip1193( + "wallet_getSupportedExecutionPermissions", + async () => hooks.getSupportedExecutionPermissions(), + ); + + wallet.registerEip1193( + "wallet_getGrantedExecutionPermissions", + async () => hooks.getGrantedExecutionPermissions(), + ); + } +} + +function normalizeProviders( + providers: Map | ReadonlyMap, +): Map { + const out = new Map(); + for (const [chainId, url] of providers) { + const id = ChainUtils.asEVMChainId(chainId); + const trimmed = url.trim(); + if (!trimmed) { + throw new Error(`RpcHelper: empty URL for chain ${id}`); + } + out.set(id, trimmed); + } + return out; +} + +function resolveDefaultChainId( + providers: Map, + defaultChainId?: EVMChainId, +): EVMChainId { + if (defaultChainId !== undefined) { + const id = ChainUtils.asEVMChainId(defaultChainId); + if (!providers.has(id)) { + throw new Error(`RpcHelper: defaultChainId ${id} is not in providers`); + } + return id; + } + const first = providers.keys().next().value; + if (first === undefined) { + throw new Error("RpcHelper: providers map must not be empty"); + } + return first; +} + +function readSwitchChainId(params: unknown[]): string { + const entry = params[0]; + if ( + entry === null || + typeof entry !== "object" || + !("chainId" in entry) || + typeof (entry as { chainId: unknown }).chainId !== "string" + ) { + throw new OwsInvalidParamsError( + "wallet_switchEthereumChain requires [{ chainId }]", + ); + } + return (entry as { chainId: string }).chainId; +} + +async function jsonRpcRequest( + url: string, + method: string, + params: unknown[], +): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params, + }), + }); + } catch (error) { + throw new OwsRpcError( + error instanceof Error ? error.message : "RPC request failed", + -32_603, + { method, url }, + ); + } + + if (!response.ok) { + throw new OwsRpcError(`RPC HTTP ${response.status}`, -32_603, { + method, + url, + status: response.status, + }); + } + + let body: { + result?: unknown; + error?: { code?: number; message?: string; data?: unknown }; + }; + try { + body = (await response.json()) as typeof body; + } catch { + throw new OwsRpcError("Invalid JSON-RPC response", -32_603, { + method, + url, + }); + } + + if (body.error) { + throw new OwsRpcError( + body.error.message ?? "RPC error", + body.error.code ?? -32_603, + body.error.data, + ); + } + + return body.result; +} diff --git a/packages/ows-wallet-utils/src/eip1193/schemas.ts b/packages/ows-wallet-utils/src/eip1193/schemas.ts new file mode 100644 index 0000000..d46dd5f --- /dev/null +++ b/packages/ows-wallet-utils/src/eip1193/schemas.ts @@ -0,0 +1,138 @@ +import { + EVMAccountAddressSchema, + EVMChainIdSchema, + HexStringSchema, + type Eip1193Method, +} from "@1shotapi/ows-types"; +import { z } from "zod"; + +const hexSchema = z.string().regex(/^0x[0-9a-fA-F]*$/); + +const transactionObjectSchema = z.object({ + from: EVMAccountAddressSchema.optional(), + to: EVMAccountAddressSchema.nullish(), + gas: hexSchema.optional(), + gasPrice: hexSchema.optional(), + maxFeePerGas: hexSchema.optional(), + maxPriorityFeePerGas: hexSchema.optional(), + value: hexSchema.optional(), + data: hexSchema.optional(), + nonce: hexSchema.optional(), + chainId: hexSchema.optional(), + type: z.string().optional(), + accessList: z.array(z.unknown()).optional(), +}); + +const typedDataObjectSchema = z.object({ + types: z.record( + z.string(), + z.array(z.object({ name: z.string(), type: z.string() })), + ), + primaryType: z.string(), + domain: z.record(z.string(), z.unknown()), + message: z.record(z.string(), z.unknown()), +}); + +/** Object or JSON string (common for eth_signTypedData_v3 / _v4). */ +const typedDataSchema = z.union([ + typedDataObjectSchema, + z.string().transform((value, ctx) => { + try { + return typedDataObjectSchema.parse(JSON.parse(value)); + } catch { + ctx.addIssue({ + code: "custom", + message: "Invalid EIP-712 typed data JSON", + }); + return z.NEVER; + } + }), +]); + +const switchChainParamsSchema = z.object({ + chainId: hexSchema, +}); + +const addChainParamsSchema = z.object({ + chainId: hexSchema, + chainName: z.string(), + nativeCurrency: z.object({ + name: z.string(), + symbol: z.string(), + decimals: z.number(), + }), + rpcUrls: z.array(z.string()), + blockExplorerUrls: z.array(z.string()).optional(), + iconUrls: z.array(z.string()).optional(), +}); + +const watchAssetParamsSchema = z.object({ + type: z.literal("ERC20"), + options: z.object({ + address: EVMAccountAddressSchema, + symbol: z.string().optional(), + decimals: z.number().optional(), + image: z.string().optional(), + }), +}); + +const executionPermissionSchema = z.object({ + type: z.string().min(1), + isAdjustmentAllowed: z.boolean(), + data: z.record(z.string(), z.unknown()), +}); + +const executionPermissionRuleSchema = z.object({ + type: z.string().min(1), + data: z.record(z.string(), z.unknown()), +}); + +/** Single EIP-7715 permission request object. */ +const executionPermissionRequestSchema = z.object({ + chainId: EVMChainIdSchema, + from: EVMAccountAddressSchema.optional(), + to: EVMAccountAddressSchema, + permission: executionPermissionSchema, + rules: z.array(executionPermissionRuleSchema).optional(), +}); + +/** + * EIP-7715: `params` is the permission request array itself + * (not wrapped as a single tuple element). + */ +const requestExecutionPermissionsParamsSchema = z.array( + executionPermissionRequestSchema, +); + +const revokeExecutionPermissionParamsSchema = z.object({ + permissionContext: HexStringSchema, +}); + +export const EIP1193_PARAM_SCHEMAS: Record = { + eth_requestAccounts: z.tuple([]), + eth_accounts: z.tuple([]), + eth_chainId: z.tuple([]), + personal_sign: z.tuple([ + z.union([hexSchema, z.string()]), + EVMAccountAddressSchema, + ]), + eth_sign: z.tuple([EVMAccountAddressSchema, z.union([hexSchema, z.string()])]), + eth_signTypedData: z.tuple([EVMAccountAddressSchema, typedDataSchema]), + eth_signTypedData_v3: z.tuple([EVMAccountAddressSchema, typedDataSchema]), + eth_signTypedData_v4: z.tuple([EVMAccountAddressSchema, typedDataSchema]), + eth_sendTransaction: z.tuple([transactionObjectSchema]), + eth_signTransaction: z.tuple([transactionObjectSchema]), + wallet_switchEthereumChain: z.tuple([switchChainParamsSchema]), + wallet_addEthereumChain: z.tuple([addChainParamsSchema]), + wallet_watchAsset: z.tuple([watchAssetParamsSchema]), + wallet_requestExecutionPermissions: requestExecutionPermissionsParamsSchema, + wallet_revokeExecutionPermission: z.tuple([ + revokeExecutionPermissionParamsSchema, + ]), + wallet_getSupportedExecutionPermissions: z.tuple([]), + wallet_getGrantedExecutionPermissions: z.tuple([]), +}; + +export function getEip1193ParamSchema(method: Eip1193Method): z.ZodType { + return EIP1193_PARAM_SCHEMAS[method]; +} diff --git a/packages/ows-wallet-utils/src/index.ts b/packages/ows-wallet-utils/src/index.ts new file mode 100644 index 0000000..14e98d9 --- /dev/null +++ b/packages/ows-wallet-utils/src/index.ts @@ -0,0 +1,62 @@ +export { OWSWallet } from "./ows-wallet.js"; +export type { + DisplaySession, + Eip1193Handler, + OWSWalletOptions, + RequestDisplayParams, + RpcHandlerRegistration, +} from "./ows-wallet.js"; + +export { AnalyticsChildClient } from "./analytics/child-client.js"; +export { Eip1193EventChildClient } from "./eip1193/event-child-client.js"; + +export { EIP1193_PARAM_SCHEMAS, getEip1193ParamSchema } from "./eip1193/schemas.js"; +export { + RpcHelper, + RpcHelperEventEmitter, +} from "./eip1193/rpc-helper.js"; +export type { + RpcHelperEventMap, + RpcHelperExecutionPermissionsHooks, + RpcHelperOptions, + RpcHelperSigner, + RpcHelperWallet, +} from "./eip1193/rpc-helper.js"; + +export { runHandler } from "./rpc/handler.js"; +export { handleRpcModelCall } from "./rpc/child-wrapper.js"; +export type { RpcModelRegistration } from "./rpc/child-wrapper.js"; +export { debugLog, isOwsWalletDebugEnabled } from "./debug.js"; + +export { + AddressUtils, +} from "./AddressUtils.js"; +export { + IBlockchainProviderType, + type IBlockchainProvider, +} from "./IBlockchainProvider.js"; + +export { + CREDENTIAL_PARAM_SCHEMAS, + acceptOfferParamsSchema, + presentParamsSchema, + listParamsSchema, + deleteParamsSchema, +} from "./credentials/schemas.js"; +export { CredentialWalletRegistrar } from "./credentials/wallet-registrar.js"; + +export { + BITCOIN_PARAM_SCHEMAS, + getAccountAddressesParamsSchema, + BitcoinWalletRegistrar, +} from "./bitcoin/wallet-registrar.js"; + +export { + SOLANA_PARAM_SCHEMAS, + getAccountAddressParamsSchema, + signMessageParamsSchema, + signTransactionParamsSchema, + signAndSendTransactionParamsSchema, + signInParamsSchema, + SolanaWalletRegistrar, +} from "./solana/wallet-registrar.js"; diff --git a/packages/ows-wallet-utils/src/ows-wallet.ts b/packages/ows-wallet-utils/src/ows-wallet.ts new file mode 100644 index 0000000..78ece8a --- /dev/null +++ b/packages/ows-wallet-utils/src/ows-wallet.ts @@ -0,0 +1,483 @@ +import Postmate from "postmate"; +import type { z } from "zod"; +import { + EIP1193_METHODS, + isEip1193Method, + OWS_DISPLAY_READY_MODEL_METHOD, + OWS_HIDE_READY_MODEL_METHOD, + OwsUnimplementedError, + type RequestDisplayParams, +} from "@1shotapi/ows-types"; +import { getEip1193ParamSchema } from "./eip1193/schemas.js"; +import { + handleRpcModelCall, + type RpcModelRegistration, +} from "./rpc/child-wrapper.js"; +import { + DisplayChildClient, + type DisplaySession, +} from "./display/child-client.js"; +import { AnalyticsChildClient } from "./analytics/child-client.js"; +import { Eip1193EventChildClient } from "./eip1193/event-child-client.js"; +import { debugLog, setOwsWalletDebugFromOptions } from "./debug.js"; +import { CredentialWalletRegistrar } from "./credentials/wallet-registrar.js"; +import { BitcoinWalletRegistrar } from "./bitcoin/wallet-registrar.js"; +import { SolanaWalletRegistrar } from "./solana/wallet-registrar.js"; +import type { + IOpenWalletBitcoinProvider, + IOpenWalletSolanaProvider, + IOWSAnalyticsEvent, + OpenWalletCredentialProvider, +} from "@1shotapi/ows-types"; + +export type { DisplaySession, RequestDisplayParams }; + +export type Eip1193Handler = (params: unknown[]) => Promise; + +export type RpcHandlerRegistration = { + handler: (params: unknown) => Promise; + paramsSchema?: z.ZodType; +}; + +export type OWSWalletOptions = { + /** Log Postmate handshake and RPC traffic to `console.debug`. */ + debug?: boolean; + /** + * EIP-1193 handlers. Keys may be standard wallet methods (`Eip1193Method`) + * or additional methods registered by branding modules (e.g. `eth_call`). + */ + eip1193?: Partial>; + rpc?: Record; +}; + +export class OWSWallet { + private childApi: Postmate.ChildAPI | null = null; + private handshakePromise: Promise | null = null; + private displayClient: DisplayChildClient | null = null; + private analyticsClient: AnalyticsChildClient | null = null; + private eip1193EventClient: Eip1193EventChildClient | null = null; + private displayReadyHandler: ((data: unknown) => void) | null = null; + private hideReadyHandler: ((data: unknown) => void) | null = null; + private readonly eip1193Handlers = new Map(); + private readonly customHandlers = new Map(); + private readonly credentialRegistrar = new CredentialWalletRegistrar(); + private readonly bitcoinRegistrar = new BitcoinWalletRegistrar(); + private readonly solanaRegistrar = new SolanaWalletRegistrar(); + private handshakeStarted = false; + + private constructor(options?: OWSWalletOptions) { + setOwsWalletDebugFromOptions(options?.debug); + if (options?.eip1193) { + for (const [method, handler] of Object.entries(options.eip1193)) { + if (handler) { + this.eip1193Handlers.set(method, handler); + } + } + } + if (options?.rpc) { + for (const [method, registration] of Object.entries(options.rpc)) { + this.customHandlers.set(method, registration); + } + } + } + + /** Build a wallet instance; call `register*` then `start()`. */ + static prepare(options?: OWSWalletOptions): OWSWallet { + if (typeof window === "undefined") { + throw new Error("OWSWallet requires a browser environment"); + } + return new OWSWallet(options); + } + + /** Namespaced verifiable credential handlers (not generic `registerRpc`). */ + readonly credentials = { + register: (handlers: OpenWalletCredentialProvider): void => { + this.assertNotConnected(); + this.credentialRegistrar.register(handlers); + }, + }; + + /** Namespaced Bitcoin handlers (e.g. BIP-122 static wallet methods). */ + readonly bitcoin = { + register: (provider: IOpenWalletBitcoinProvider): void => { + this.assertNotConnected(); + this.bitcoinRegistrar.register(provider); + }, + }; + + /** Namespaced Solana handlers (Host Standard wallet uses these via Postmate). */ + readonly solana = { + register: (provider: IOpenWalletSolanaProvider): void => { + this.assertNotConnected(); + this.solanaRegistrar.register(provider); + }, + }; + + /** + * Branding → Host analytics. Emits the full event object over Postmate + * (`ows:analytics`). Extra fields beyond the OWS base are branding-owned. + */ + readonly analytics = { + emit: (event: IOWSAnalyticsEvent): void => { + const client = this.getAnalyticsClient(); + if (!client) { + debugLog("analytics emit skipped; wallet not connected", { + name: event.name, + }); + return; + } + client.emit(event); + }, + }; + + /** + * Branding → Host EIP-1193 provider notifications (`ows:eip1193`). + * Use for `chainChanged`, `accountsChanged`, etc. Host apps subscribe with + * `proxy.ethereum.on(event, listener)`. + */ + readonly providerEvents = { + emit: (event: string, ...params: unknown[]): void => { + const client = this.getEip1193EventClient(); + if (!client) { + debugLog("eip1193 event emit skipped; wallet not connected", { + event, + }); + return; + } + client.emit(event, ...params); + }, + }; + + static async create(options?: OWSWalletOptions): Promise { + return OWSWallet.prepare(options).start(); + } + + async start(): Promise { + if (this.handshakeStarted) { + throw new Error("OWSWallet handshake already started"); + } + this.handshakeStarted = true; + + const model = this.buildPostmateModel(); + debugLog("starting Postmate child handshake", { + eip1193Handlers: [...this.eip1193Handlers.keys()], + customRpc: [...this.customHandlers.keys()], + credentialRpc: this.credentialRegistrar.getWireMethods(), + modelMethods: Object.keys(model).length, + }); + + this.handshakePromise = new Postmate.Model(model); + this.childApi = await this.handshakePromise; + this.displayClient = new DisplayChildClient(this.childApi); + this.analyticsClient = new AnalyticsChildClient(this.childApi); + this.eip1193EventClient = new Eip1193EventChildClient(this.childApi); + this.displayReadyHandler = (data) => { + this.displayClient?.handleDisplayReady(data); + }; + this.hideReadyHandler = (data) => { + this.displayClient?.handleHideReady(data); + }; + + debugLog("Postmate child connected; RPC model ready"); + + return this; + } + + /** + * Ask the host to show and focus this iframe so WebAuthn / UI can run in a + * cross-origin embedding. Resolves when the host confirms display is ready. + */ + async requestDisplay( + params: RequestDisplayParams = {}, + ): Promise { + const childApi = + this.childApi ?? (await this.handshakePromise); + if (!childApi) { + throw new Error("OWSWallet is not connected to a host"); + } + + if (!this.displayClient) { + this.displayClient = new DisplayChildClient(childApi); + } + + return this.displayClient.requestDisplay(params); + } + + /** Ask the host to hide the wallet panel (e.g. user dismissed the flyout). */ + async requestHide(): Promise { + const childApi = + this.childApi ?? (await this.handshakePromise); + if (!childApi) { + throw new Error("OWSWallet is not connected to a host"); + } + + if (!this.displayClient) { + this.displayClient = new DisplayChildClient(childApi); + } + + return this.displayClient.requestHide(); + } + + /** + * Register an EIP-1193 method handler. Standard wallet methods are always + * exposed on the Postmate model; additional methods (e.g. `eth_call`) are + * added when registered so host `ethereum.request` can reach them. + */ + registerEip1193(method: string, handler: Eip1193Handler): void { + this.assertNotConnected(); + this.eip1193Handlers.set(method, handler); + } + + registerRpc( + method: string, + handler: (params: unknown) => Promise, + paramsSchema?: z.ZodType, + ): void { + this.assertNotConnected(); + this.customHandlers.set(method, { handler, paramsSchema }); + } + + destroy(): void { + this.displayClient?.destroy(); + this.displayClient = null; + this.analyticsClient = null; + this.eip1193EventClient = null; + this.displayReadyHandler = null; + this.hideReadyHandler = null; + this.childApi = null; + } + + private getAnalyticsClient(): AnalyticsChildClient | null { + if (this.analyticsClient) { + return this.analyticsClient; + } + if (!this.childApi) { + return null; + } + this.analyticsClient = new AnalyticsChildClient(this.childApi); + return this.analyticsClient; + } + + private getEip1193EventClient(): Eip1193EventChildClient | null { + if (this.eip1193EventClient) { + return this.eip1193EventClient; + } + if (!this.childApi) { + return null; + } + this.eip1193EventClient = new Eip1193EventChildClient(this.childApi); + return this.eip1193EventClient; + } + + private assertNotConnected(): void { + if (this.handshakeStarted) { + throw new Error( + "Cannot register handlers after OWSWallet.create(); pass handlers in OWSWalletOptions", + ); + } + } + + private buildPostmateModel(): Record< + string, + (data: unknown) => Promise + > { + const model: Record Promise> = {}; + + model[OWS_DISPLAY_READY_MODEL_METHOD] = async (data) => { + this.displayReadyHandler?.(data); + }; + + model[OWS_HIDE_READY_MODEL_METHOD] = async (data) => { + this.hideReadyHandler?.(data); + }; + + const eip1193Methods = new Set([ + ...EIP1193_METHODS, + ...this.eip1193Handlers.keys(), + ]); + for (const method of eip1193Methods) { + model[method] = async (data) => { + await this.dispatch(method, data, true); + }; + } + + for (const method of this.customHandlers.keys()) { + if (!(method in model)) { + model[method] = async (data) => { + await this.dispatch(method, data, false); + }; + } + } + + for (const wireKey of this.credentialRegistrar.getWireMethods()) { + if (!(wireKey in model)) { + model[wireKey] = async (data) => { + await this.dispatchCredential(wireKey, data); + }; + } + } + + for (const wireKey of this.bitcoinRegistrar.registeredWireMethods()) { + if (!(wireKey in model)) { + model[wireKey] = async (data) => { + await this.dispatchBitcoin(wireKey, data); + }; + } + } + + for (const wireKey of this.solanaRegistrar.registeredWireMethods()) { + if (!(wireKey in model)) { + model[wireKey] = async (data) => { + await this.dispatchSolana(wireKey, data); + }; + } + } + + return model; + } + + private async dispatchCredential(wireKey: string, data: unknown): Promise { + debugLog("Credential RPC model invoked", { wireKey, raw: data }); + + const childApi = + this.childApi ?? (await this.handshakePromise); + if (!childApi) { + debugLog("Credential RPC dropped — child API not available", { wireKey }); + return; + } + + const registration = this.credentialRegistrar.getRegistration(wireKey); + if (!registration) { + await handleRpcModelCall( + childApi, + data, + { + handler: async () => { + throw new OwsUnimplementedError( + `Credential method not registered: ${wireKey}`, + ); + }, + }, + wireKey, + ); + return; + } + + await handleRpcModelCall(childApi, data, registration, wireKey); + } + + private async dispatchBitcoin( + wireKey: string, + data: unknown, + ): Promise { + const childApi = + this.childApi ?? (await this.handshakePromise); + if (!childApi) { + debugLog("Bitcoin RPC dropped — child API not available", { wireKey }); + return; + } + + const registration = this.bitcoinRegistrar.getRegistration(wireKey); + if (!registration) { + await handleRpcModelCall( + childApi, + data, + { + handler: async () => { + throw new OwsUnimplementedError( + `Bitcoin handler not registered for wire method: ${wireKey}`, + ); + }, + }, + wireKey, + ); + return; + } + + await handleRpcModelCall(childApi, data, registration, wireKey); + } + + private async dispatchSolana( + wireKey: string, + data: unknown, + ): Promise { + const childApi = + this.childApi ?? (await this.handshakePromise); + if (!childApi) { + debugLog("Solana RPC dropped — child API not available", { wireKey }); + return; + } + + const registration = this.solanaRegistrar.getRegistration(wireKey); + if (!registration) { + await handleRpcModelCall( + childApi, + data, + { + handler: async () => { + throw new OwsUnimplementedError( + `Solana handler not registered for wire method: ${wireKey}`, + ); + }, + }, + wireKey, + ); + return; + } + + await handleRpcModelCall(childApi, data, registration, wireKey); + } + + private async dispatch( + method: string, + data: unknown, + isEip1193: boolean, + ): Promise { + debugLog("RPC model invoked", { method, isEip1193, raw: data }); + + const childApi = + this.childApi ?? (await this.handshakePromise); + if (!childApi) { + debugLog("RPC dropped — child API not available", { method }); + return; + } + + const registration = this.resolveRegistration(method, isEip1193); + await handleRpcModelCall(childApi, data, registration, method); + } + + private resolveRegistration( + method: string, + isEip1193: boolean, + ): RpcModelRegistration { + if (isEip1193) { + const handler = this.eip1193Handlers.get(method); + const paramsSchema = isEip1193Method(method) + ? getEip1193ParamSchema(method) + : undefined; + if (handler) { + return { + paramsSchema, + handler: async (params) => handler(params as unknown[]), + }; + } + return { + paramsSchema, + handler: async () => { + throw new OwsUnimplementedError(`EIP-1193 method not implemented: ${method}`); + }, + }; + } + + const custom = this.customHandlers.get(method); + if (custom) { + return custom; + } + + return { + handler: async () => { + throw new OwsUnimplementedError(`RPC method not implemented: ${method}`); + }, + }; + } +} diff --git a/packages/ows-wallet-utils/src/rpc/child-wrapper.ts b/packages/ows-wallet-utils/src/rpc/child-wrapper.ts new file mode 100644 index 0000000..ee93f0c --- /dev/null +++ b/packages/ows-wallet-utils/src/rpc/child-wrapper.ts @@ -0,0 +1,112 @@ +import type Postmate from "postmate"; +import { + OWS_RPC_CALLBACK_EVENT, + OwsRpcError, + RPCCallId, + deserializeRpcRequest, + serializeRpc, + type RpcResponseEnvelope, +} from "@1shotapi/ows-types"; +import { runHandler } from "./handler.js"; +import type { z } from "zod"; +import { debugLog } from "../debug.js"; + +export type RpcModelHandler = (params: unknown) => Promise; + +export type RpcModelRegistration = { + handler: RpcModelHandler; + paramsSchema?: z.ZodType; +}; + +export async function handleRpcModelCall( + childApi: Postmate.ChildAPI, + data: unknown, + registration: RpcModelRegistration, + methodLabel?: string, +): Promise { + let envelope; + try { + envelope = deserializeRpcRequest(data); + } catch (error) { + debugLog("RPC request deserialize failed", { + method: methodLabel, + error: error instanceof Error ? error.message : error, + }); + emitError( + childApi, + RPCCallId(-1), + error instanceof Error ? error.message : "Invalid RPC request", + -32_600, + ); + return; + } + + debugLog("RPC request received", { + method: methodLabel ?? envelope.method, + callId: envelope.callId, + params: envelope.params, + }); + + try { + const result = await runHandler( + envelope.params, + registration.paramsSchema, + registration.handler, + ); + const response = { + callId: envelope.callId, + success: true, + result, + } satisfies RpcResponseEnvelope; + debugLog("RPC response success", { + method: methodLabel ?? envelope.method, + callId: envelope.callId, + result, + }); + childApi.emit(OWS_RPC_CALLBACK_EVENT, serializeRpc(response)); + } catch (error) { + if (error instanceof OwsRpcError) { + const response = { + callId: envelope.callId, + success: false, + error: error.toPayload(), + } satisfies RpcResponseEnvelope; + debugLog("RPC response error", { + method: methodLabel ?? envelope.method, + callId: envelope.callId, + error: error.toPayload(), + }); + childApi.emit(OWS_RPC_CALLBACK_EVENT, serializeRpc(response)); + return; + } + + const response = { + callId: envelope.callId, + success: false, + error: { + code: -32_603, + message: error instanceof Error ? error.message : "Internal error", + }, + } satisfies RpcResponseEnvelope; + debugLog("RPC response internal error", { + method: methodLabel ?? envelope.method, + callId: envelope.callId, + error: response.error, + }); + childApi.emit(OWS_RPC_CALLBACK_EVENT, serializeRpc(response)); + } +} + +function emitError( + childApi: Postmate.ChildAPI, + callId: RPCCallId, + message: string, + code: number, +): void { + const response = { + callId, + success: false, + error: { code, message }, + } satisfies RpcResponseEnvelope; + childApi.emit(OWS_RPC_CALLBACK_EVENT, serializeRpc(response)); +} diff --git a/packages/ows-wallet-utils/src/rpc/handler.ts b/packages/ows-wallet-utils/src/rpc/handler.ts new file mode 100644 index 0000000..2fe55d7 --- /dev/null +++ b/packages/ows-wallet-utils/src/rpc/handler.ts @@ -0,0 +1,24 @@ +import type { z } from "zod"; +import { OwsInvalidParamsError, OwsRpcError } from "@1shotapi/ows-types"; + +export async function runHandler( + params: unknown, + schema: z.ZodType | undefined, + handler: (validated: T) => Promise, +): Promise { + try { + if (schema) { + const parsed = schema.safeParse(params); + if (!parsed.success) { + throw new OwsInvalidParamsError("Invalid params", parsed.error.flatten()); + } + return await handler(parsed.data); + } + return await handler(params as T); + } catch (error) { + if (error instanceof OwsRpcError) { + throw error; + } + throw error; + } +} diff --git a/packages/ows-wallet-utils/src/solana/wallet-registrar.ts b/packages/ows-wallet-utils/src/solana/wallet-registrar.ts new file mode 100644 index 0000000..78e5663 --- /dev/null +++ b/packages/ows-wallet-utils/src/solana/wallet-registrar.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { + Base64StringSchema, + DomainStringSchema, + ISO8601DateTimeSchema, + SOLANA_WIRE_METHODS, + SolanaAccountAddressSchema, + SolanaChainIdSchema, + UriStringSchema, + type IOpenWalletSolanaProvider, +} from "@1shotapi/ows-types"; + +export const getAccountAddressParamsSchema = z + .object({ + chainId: SolanaChainIdSchema.optional(), + }) + .optional() + .default({}); + +export const signMessageParamsSchema = z.object({ + messages: z.array(Base64StringSchema).min(1), +}); + +export const signTransactionParamsSchema = z.object({ + transactions: z.array(Base64StringSchema).min(1), +}); + +export const signAndSendTransactionParamsSchema = z.object({ + transaction: Base64StringSchema, + chainId: SolanaChainIdSchema.optional(), +}); + +export const signInParamsSchema = z + .object({ + domain: DomainStringSchema.optional(), + address: SolanaAccountAddressSchema.optional(), + statement: z.string().optional(), + uri: UriStringSchema.optional(), + version: z.string().optional(), + chainId: SolanaChainIdSchema.optional(), + nonce: z.string().optional(), + issuedAt: ISO8601DateTimeSchema.optional(), + expirationTime: ISO8601DateTimeSchema.optional(), + notBefore: ISO8601DateTimeSchema.optional(), + requestId: z.string().optional(), + resources: z.array(UriStringSchema).optional(), + }) + .optional() + .default({}); + +export const SOLANA_PARAM_SCHEMAS = { + getAccountAddress: getAccountAddressParamsSchema, + signMessage: signMessageParamsSchema, + signTransaction: signTransactionParamsSchema, + signAndSendTransaction: signAndSendTransactionParamsSchema, + signIn: signInParamsSchema, +} as const; + +type RpcHandlerRegistration = { + handler: (params: unknown) => Promise; + paramsSchema?: z.ZodType; +}; + +export class SolanaWalletRegistrar { + private readonly handlers = new Map(); + + register(provider: IOpenWalletSolanaProvider): void { + this.handlers.set(SOLANA_WIRE_METHODS.getAccountAddress, { + handler: (params: unknown) => + provider.getAccountAddress( + params as Parameters[0], + ), + paramsSchema: SOLANA_PARAM_SCHEMAS.getAccountAddress, + }); + this.handlers.set(SOLANA_WIRE_METHODS.signMessage, { + handler: (params: unknown) => + provider.signMessage( + params as Parameters[0], + ), + paramsSchema: SOLANA_PARAM_SCHEMAS.signMessage, + }); + this.handlers.set(SOLANA_WIRE_METHODS.signTransaction, { + handler: (params: unknown) => + provider.signTransaction( + params as Parameters[0], + ), + paramsSchema: SOLANA_PARAM_SCHEMAS.signTransaction, + }); + this.handlers.set(SOLANA_WIRE_METHODS.signAndSendTransaction, { + handler: (params: unknown) => + provider.signAndSendTransaction( + params as Parameters< + IOpenWalletSolanaProvider["signAndSendTransaction"] + >[0], + ), + paramsSchema: SOLANA_PARAM_SCHEMAS.signAndSendTransaction, + }); + this.handlers.set(SOLANA_WIRE_METHODS.signIn, { + handler: (params: unknown) => + provider.signIn( + params as Parameters[0], + ), + paramsSchema: SOLANA_PARAM_SCHEMAS.signIn, + }); + } + + getRegistration(wireMethod: string): RpcHandlerRegistration | undefined { + return this.handlers.get(wireMethod); + } + + registeredWireMethods(): string[] { + return Array.from(this.handlers.keys()); + } +} diff --git a/packages/ows-wallet-utils/test/address-utils.test.ts b/packages/ows-wallet-utils/test/address-utils.test.ts new file mode 100644 index 0000000..d94b8f7 --- /dev/null +++ b/packages/ows-wallet-utils/test/address-utils.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getAddress } from "viem"; +import { + BITCOIN_MAINNET_CHAIN_ID, + BITCOIN_TESTNET_CHAIN_ID, + EVMChainId, +} from "@1shotapi/ows-types"; +import { AddressUtils } from "../src/AddressUtils.js"; +import type { IBlockchainProvider } from "../src/IBlockchainProvider.js"; + +const CHAIN = EVMChainId("0x1"); +const SAMPLE_HEX = "0xAbCdEf0123456789AbCdEf0123456789aBcDeF01"; +const SAMPLE_CHECKSUM = getAddress(SAMPLE_HEX); + +function mockProvider(): IBlockchainProvider { + return { + getPublicClient() { + return { + request: async () => null, + } as never; + }, + }; +} + +describe("AddressUtils", () => { + it("returns EIP-55 checksummed EVM addresses", async () => { + const utils = new AddressUtils(mockProvider()); + const a = await utils.validateEVMAddress(SAMPLE_HEX, CHAIN); + const b = await utils.validateEVMAddress( + "AbCdEf0123456789AbCdEf0123456789aBcDeF01", + CHAIN, + ); + const c = await utils.validateEVMAddress(SAMPLE_HEX.toLowerCase(), CHAIN); + assert.equal(a, SAMPLE_CHECKSUM); + assert.equal(b, SAMPLE_CHECKSUM); + assert.equal(c, SAMPLE_CHECKSUM); + }); + + it("rejects invalid EVM addresses", async () => { + const utils = new AddressUtils(mockProvider()); + await assert.rejects(() => utils.validateEVMAddress("0x1234", CHAIN)); + await assert.rejects(() => utils.validateEVMAddress("", CHAIN)); + }); + + it("validates Solana base58", () => { + const utils = new AddressUtils(mockProvider()); + const addr = utils.validateSolanaAddress( + "7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDqvc3ei82", + ); + assert.equal(typeof addr, "string"); + assert.throws(() => utils.validateSolanaAddress("0xnotsolana")); + }); + + it("validates Bitcoin Native SegWit (P2WPKH) addresses on mainnet and testnet", () => { + const utils = new AddressUtils(mockProvider()); + + // Valid mainnet P2WPKH + const mainnet = utils.validateBitcoinSegwitAddress( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + ); + assert.equal(mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); + + // Valid testnet P2WPKH + const testnet = utils.validateBitcoinSegwitAddress( + "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + BITCOIN_TESTNET_CHAIN_ID, + ); + assert.equal(testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"); + + // Rejects testnet on mainnet + assert.throws(() => + utils.validateBitcoinSegwitAddress( + "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + BITCOIN_MAINNET_CHAIN_ID, + ), + ); + + // Rejects mainnet on testnet + assert.throws(() => + utils.validateBitcoinSegwitAddress( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + BITCOIN_TESTNET_CHAIN_ID, + ), + ); + + // Rejects Taproot (bc1p...) + assert.throws(() => + utils.validateBitcoinSegwitAddress( + "bc1p5d7rjq7g6rdk2yhzks9s2cqmmxdumgah52q6g27ur76rw0vd2hsjqxpxaq", + ), + ); + + // Rejects legacy Base58 address + assert.throws(() => + utils.validateBitcoinSegwitAddress( + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + ), + ); + + // Rejects hex + assert.throws(() => + utils.validateBitcoinSegwitAddress( + "0xabcdef0123456789abcdef0123456789abcdef01", + ), + ); + }); +}); diff --git a/packages/ows-wallet-utils/test/display-child.test.ts b/packages/ows-wallet-utils/test/display-child.test.ts new file mode 100644 index 0000000..7d3bfd4 --- /dev/null +++ b/packages/ows-wallet-utils/test/display-child.test.ts @@ -0,0 +1,105 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + DisplayRequestId, + OWS_DISPLAY_READY_MODEL_METHOD, + OWS_HIDE_READY_MODEL_METHOD, + OWS_RELEASE_DISPLAY_EVENT, + OWS_REQUEST_DISPLAY_EVENT, + OWS_REQUEST_HIDE_EVENT, + serializeRpc, +} from "@1shotapi/ows-types"; +import { DisplayChildClient } from "../src/display/child-client.ts"; + +describe("DisplayChildClient", () => { + const emitted: Array<{ name: string; data: string }> = []; + const childApi = { + emit(name: string, data: string) { + emitted.push({ name, data }); + }, + }; + + afterEach(() => { + emitted.length = 0; + }); + + it("emits requestDisplay and resolves on display ready", async () => { + const client = new DisplayChildClient(childApi as never); + const pending = client.requestDisplay(); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0]?.name, OWS_REQUEST_DISPLAY_EVENT); + const request = JSON.parse(emitted[0]!.data) as { + displayId: string; + }; + + client.handleDisplayReady(serializeRpc({ displayId: request.displayId })); + const session = await pending; + assert.equal(session.displayId, DisplayRequestId(request.displayId)); + }); + + it("reuses an active session until release", async () => { + const client = new DisplayChildClient(childApi as never); + const first = client.requestDisplay(); + const request = JSON.parse(emitted[0]!.data) as { displayId: string }; + client.handleDisplayReady(serializeRpc({ displayId: request.displayId })); + const session = await first; + + emitted.length = 0; + const second = await client.requestDisplay(); + assert.equal(emitted.length, 0); + assert.equal(second.displayId, session.displayId); + + session.release(); + assert.equal(emitted.length, 0); + + session.release(); + assert.equal(emitted.length, 1); + assert.equal(emitted[0]?.name, OWS_RELEASE_DISPLAY_EVENT); + }); + + it("stale release still decrements the live session", async () => { + const client = new DisplayChildClient(childApi as never); + const firstPending = client.requestDisplay(); + const firstReq = JSON.parse(emitted[0]!.data) as { displayId: string }; + + // Parallel acquire before either is ready (both pending). + const secondPending = client.requestDisplay(); + const secondReq = JSON.parse(emitted[1]!.data) as { displayId: string }; + + client.handleDisplayReady(serializeRpc({ displayId: firstReq.displayId })); + const first = await firstPending; + + client.handleDisplayReady(serializeRpc({ displayId: secondReq.displayId })); + const second = await secondPending; + + emitted.length = 0; + // Superseded handle — must still decrement live depth (not silent no-op). + first.release(); + assert.equal(emitted.length, 0); + + second.release(); + assert.equal(emitted.length, 1); + assert.equal(emitted[0]?.name, OWS_RELEASE_DISPLAY_EVENT); + const payload = JSON.parse(emitted[0]!.data) as { displayId: string }; + assert.equal(payload.displayId, secondReq.displayId); + }); + + it("requestHide resolves on hide ready", async () => { + const client = new DisplayChildClient(childApi as never); + const pending = client.requestHide(); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0]?.name, OWS_REQUEST_HIDE_EVENT); + + client.handleHideReady(serializeRpc({})); + await pending; + }); +}); + +describe("display model method names", () => { + it("uses reserved Postmate model method names", () => { + assert.equal(OWS_DISPLAY_READY_MODEL_METHOD, "__owsDisplayReady"); + assert.equal(OWS_HIDE_READY_MODEL_METHOD, "__owsHideReady"); + }); +}); diff --git a/packages/ows-wallet-utils/test/ows-wallet.test.ts b/packages/ows-wallet-utils/test/ows-wallet.test.ts new file mode 100644 index 0000000..00e1f4d --- /dev/null +++ b/packages/ows-wallet-utils/test/ows-wallet.test.ts @@ -0,0 +1,112 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import { + OWS_RPC_CALLBACK_EVENT, + OwsUnimplementedError, + deserializeRpcResponse, + serializeRpc, + RPCCallId, +} from "@1shotapi/ows-types"; +import { handleRpcModelCall } from "../src/rpc/child-wrapper.ts"; + +describe("handleRpcModelCall", () => { + const emitted: Array<{ name: string; data: string }> = []; + const childApi = { + emit(name: string, data: string) { + emitted.push({ name, data }); + }, + }; + + afterEach(() => { + emitted.length = 0; + }); + + it("returns success via ows:rpcCallback", async () => { + await handleRpcModelCall( + childApi, + serializeRpc({ callId: RPCCallId(1), method: "custom", params: { foo: 1 } }), + { + handler: async (params) => ({ echo: params }), + }); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0]?.name, OWS_RPC_CALLBACK_EVENT); + const response = deserializeRpcResponse(emitted[0]!.data); + assert.equal(response.success, true); + assert.deepEqual(response.result, { echo: { foo: 1 } }); + }); + + it("returns unimplemented error envelope", async () => { + await handleRpcModelCall( + childApi, + serializeRpc({ callId: RPCCallId(2), method: "missing", params: null }), + { + handler: async () => { + throw new OwsUnimplementedError("nope"); + }, + }); + + const response = deserializeRpcResponse(emitted[0]!.data); + assert.equal(response.success, false); + assert.equal(response.error?.code, -32_601); + }); + + it("validates params with zod schema", async () => { + await handleRpcModelCall( + childApi, + serializeRpc({ callId: RPCCallId(3), method: "custom", params: { bad: true } }), + { + paramsSchema: z.object({ foo: z.number() }), + handler: async () => "ok", + }, + ); + + const response = deserializeRpcResponse(emitted[0]!.data); + assert.equal(response.success, false); + assert.equal(response.error?.code, -32_602); + }); + + it("dispatches Bitcoin getAccountAddresses through BitcoinWalletRegistrar", async () => { + const { BitcoinWalletRegistrar } = await import( + "../src/bitcoin/wallet-registrar.ts" + ); + const registrar = new BitcoinWalletRegistrar(); + let calledWith: unknown = null; + registrar.register({ + getAccountAddresses: async (params) => { + calledWith = params; + return [ + { + address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" as never, + intention: "payment", + }, + ]; + }, + }); + + const reg = registrar.getRegistration("bitcoin.getAccountAddresses"); + assert.ok(reg); + + await handleRpcModelCall( + childApi, + serializeRpc({ + callId: RPCCallId(4), + method: "bitcoin.getAccountAddresses", + params: { chainId: "Bitcoin" }, + }), + reg, + "bitcoin.getAccountAddresses", + ); + + assert.deepEqual(calledWith, { chainId: "Bitcoin" }); + const response = deserializeRpcResponse(emitted[0]!.data); + assert.equal(response.success, true); + assert.deepEqual(response.result, [ + { + address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + intention: "payment", + }, + ]); + }); +}); diff --git a/packages/ows-wallet-utils/test/protocol.test.ts b/packages/ows-wallet-utils/test/protocol.test.ts new file mode 100644 index 0000000..2a8a25d --- /dev/null +++ b/packages/ows-wallet-utils/test/protocol.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; +import { + deserializeRpcRequest, + deserializeRpcResponse, + OwsInvalidParamsError, + RPCCallId, + serializeRpc, +} from "@1shotapi/ows-types"; +import { getEip1193ParamSchema } from "../src/eip1193/schemas.js"; +import { runHandler } from "../src/rpc/handler.js"; + +describe("protocol/serde", () => { + it("roundtrips request and response envelopes", () => { + const request = { callId: RPCCallId(1), method: "eth_chainId", params: [] }; + assert.deepEqual(deserializeRpcRequest(serializeRpc(request)), request); + + const response = { callId: RPCCallId(1), success: true, result: "0x1" }; + assert.deepEqual(deserializeRpcResponse(serializeRpc(response)), response); + }); +}); + +describe("eip1193/schemas", () => { + it("validates personal_sign params", () => { + const schema = getEip1193ParamSchema("personal_sign"); + const result = schema.safeParse([ + "0x6869", + "0x0000000000000000000000000000000000000001", + ]); + assert.equal(result.success, true); + }); + + it("rejects invalid personal_sign params", () => { + const schema = getEip1193ParamSchema("personal_sign"); + const result = schema.safeParse(["hello"]); + assert.equal(result.success, false); + }); +}); + +describe("rpc/handler", () => { + it("throws OwsInvalidParamsError when schema fails", async () => { + await assert.rejects( + runHandler("bad", z.tuple([z.string()]), async () => "ok"), + OwsInvalidParamsError, + ); + }); +}); diff --git a/packages/ows-wallet-utils/test/rpc-helper.test.ts b/packages/ows-wallet-utils/test/rpc-helper.test.ts new file mode 100644 index 0000000..bdbace5 --- /dev/null +++ b/packages/ows-wallet-utils/test/rpc-helper.test.ts @@ -0,0 +1,436 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; +import { + EIP1193_READ_METHODS, + EIP1193_UNRECOGNIZED_CHAIN_ID, + EVMAccountAddress, + EVMChainId, + HexString, + OwsInvalidParamsError, + OwsRpcError, +} from "@1shotapi/ows-types"; +import { + RpcHelper, + type Eip1193Handler, + type RpcHelperWallet, +} from "../src/index.ts"; + +function createMockWallet(): { + wallet: RpcHelperWallet; + handlers: Map; +} { + const handlers = new Map(); + return { + handlers, + wallet: { + registerEip1193(method, handler) { + handlers.set(method, handler); + }, + }, + }; +} + +const SEPOLIA = EVMChainId("0xaa36a7"); +const BASE_SEPOLIA = EVMChainId("0x14a34"); + +describe("RpcHelper", () => { + it("registers eth_chainId, switch, and read methods", () => { + const { wallet, handlers } = createMockWallet(); + new RpcHelper( + new Map([[SEPOLIA, "https://example.invalid"]]), + wallet, + null, + ); + + assert.equal(typeof handlers.get("eth_chainId"), "function"); + assert.equal( + typeof handlers.get("wallet_switchEthereumChain"), + "function", + ); + for (const method of EIP1193_READ_METHODS) { + assert.equal(typeof handlers.get(method), "function", method); + } + }); + + it("defaults to first provider chain", async () => { + const { wallet, handlers } = createMockWallet(); + const helper = new RpcHelper( + new Map([ + [SEPOLIA, "https://sepolia.example"], + [BASE_SEPOLIA, "https://base.example"], + ]), + wallet, + ); + + assert.equal(helper.getChainId(), SEPOLIA); + assert.equal(await handlers.get("eth_chainId")!([]), SEPOLIA); + }); + + it("honors defaultChainId", () => { + const { wallet } = createMockWallet(); + const helper = new RpcHelper( + new Map([ + [SEPOLIA, "https://sepolia.example"], + [BASE_SEPOLIA, "https://base.example"], + ]), + wallet, + undefined, + { defaultChainId: BASE_SEPOLIA }, + ); + assert.equal(helper.getChainId(), BASE_SEPOLIA); + }); + + it("switchChain updates id and emits chainChanged", async () => { + const { wallet, handlers } = createMockWallet(); + const helper = new RpcHelper( + new Map([ + [SEPOLIA, "https://sepolia.example"], + [BASE_SEPOLIA, "https://base.example"], + ]), + wallet, + ); + + const seen: EVMChainId[] = []; + helper.events.on("chainChanged", (id) => { + seen.push(id); + }); + + await helper.switchChain(BASE_SEPOLIA); + assert.equal(helper.getChainId(), BASE_SEPOLIA); + assert.deepEqual(seen, [BASE_SEPOLIA]); + + await handlers.get("wallet_switchEthereumChain")!([ + { chainId: SEPOLIA }, + ]); + assert.equal(helper.getChainId(), SEPOLIA); + }); + + it("rejects unknown chains with EIP-1193 code 4902", async () => { + const { wallet } = createMockWallet(); + const helper = new RpcHelper( + new Map([[SEPOLIA, "https://sepolia.example"]]), + wallet, + ); + + await assert.rejects( + () => helper.switchChain(BASE_SEPOLIA), + (error: unknown) => + error instanceof OwsRpcError && + error.code === EIP1193_UNRECOGNIZED_CHAIN_ID, + ); + }); + + it("beforeSwitchChain can veto a switch", async () => { + const { wallet, handlers } = createMockWallet(); + const helper = new RpcHelper( + new Map([ + [SEPOLIA, "https://sepolia.example"], + [BASE_SEPOLIA, "https://base.example"], + ]), + wallet, + null, + { + beforeSwitchChain: async (chainId) => chainId !== BASE_SEPOLIA, + }, + ); + + await handlers.get("wallet_switchEthereumChain")!([ + { chainId: BASE_SEPOLIA }, + ]); + assert.equal(helper.getChainId(), SEPOLIA); + }); + + it("proxies read methods via fetch", async () => { + const { wallet, handlers } = createMockWallet(); + new RpcHelper( + new Map([[SEPOLIA, "https://rpc.example"]]), + wallet, + ); + + const fetchMock = mock.method( + globalThis, + "fetch", + async () => + new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + try { + const result = await handlers.get("eth_blockNumber")!([]); + assert.equal(result, "0x1"); + assert.equal(fetchMock.mock.callCount(), 1); + const [url, init] = fetchMock.mock.calls[0]!.arguments as [ + string, + RequestInit, + ]; + assert.equal(url, "https://rpc.example"); + const body = JSON.parse(String(init.body)) as { + method: string; + params: unknown[]; + }; + assert.equal(body.method, "eth_blockNumber"); + assert.deepEqual(body.params, []); + } finally { + fetchMock.mock.restore(); + } + }); + + it("maps JSON-RPC errors to OwsRpcError", async () => { + const { wallet, handlers } = createMockWallet(); + new RpcHelper( + new Map([[SEPOLIA, "https://rpc.example"]]), + wallet, + ); + + const fetchMock = mock.method( + globalThis, + "fetch", + async () => + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: -32_000, message: "server error" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + try { + await assert.rejects( + () => handlers.get("eth_call")!([{ to: "0x1" }, "latest"]), + (error: unknown) => + error instanceof OwsRpcError && + error.code === -32_000 && + error.message === "server error", + ); + } finally { + fetchMock.mock.restore(); + } + }); + + it("request() uses the active chain provider", async () => { + const { wallet } = createMockWallet(); + const helper = new RpcHelper( + new Map([ + [SEPOLIA, "https://sepolia.example"], + [BASE_SEPOLIA, "https://base.example"], + ]), + wallet, + ); + + const fetchMock = mock.method( + globalThis, + "fetch", + async () => + new Response( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0xabc" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + try { + const result = await helper.request("eth_getBalance", [ + "0x1111111111111111111111111111111111111111", + "latest", + ]); + assert.equal(result, "0xabc"); + const [url, init] = fetchMock.mock.calls[0]!.arguments as [ + string, + RequestInit, + ]; + assert.equal(url, "https://sepolia.example"); + const body = JSON.parse(String(init.body)) as { + method: string; + params: unknown[]; + }; + assert.equal(body.method, "eth_getBalance"); + assert.equal(body.params.length, 2); + } finally { + fetchMock.mock.restore(); + } + }); + + it("sendRawTransaction returns a branded hash", async () => { + const { wallet } = createMockWallet(); + const helper = new RpcHelper( + new Map([[SEPOLIA, "https://rpc.example"]]), + wallet, + ); + const hash = + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + const fetchMock = mock.method( + globalThis, + "fetch", + async () => + new Response( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: hash }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + try { + const result = await helper.sendRawTransaction( + "0xsignedraw" as `0x${string}`, + ); + assert.equal(result, hash); + const [, init] = fetchMock.mock.calls[0]!.arguments as [ + string, + RequestInit, + ]; + const body = JSON.parse(String(init.body)) as { + method: string; + params: unknown[]; + }; + assert.equal(body.method, "eth_sendRawTransaction"); + assert.deepEqual(body.params, ["0xsignedraw"]); + } finally { + fetchMock.mock.restore(); + } + }); + + it("sendRawTransaction rejects invalid hash results", async () => { + const { wallet } = createMockWallet(); + const helper = new RpcHelper( + new Map([[SEPOLIA, "https://rpc.example"]]), + wallet, + ); + + const fetchMock = mock.method( + globalThis, + "fetch", + async () => + new Response( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0xbad" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + try { + await assert.rejects( + () => helper.sendRawTransaction("0xsignedraw" as `0x${string}`), + (error: unknown) => error instanceof OwsRpcError, + ); + } finally { + fetchMock.mock.restore(); + } + }); + + it("does not register EIP-7715 methods without executionPermissions hooks", () => { + const { wallet, handlers } = createMockWallet(); + new RpcHelper( + new Map([[SEPOLIA, "https://example.invalid"]]), + wallet, + ); + + assert.equal( + handlers.has("wallet_requestExecutionPermissions"), + false, + ); + assert.equal(handlers.has("wallet_revokeExecutionPermission"), false); + assert.equal( + handlers.has("wallet_getSupportedExecutionPermissions"), + false, + ); + assert.equal( + handlers.has("wallet_getGrantedExecutionPermissions"), + false, + ); + }); + + it("registers EIP-7715 methods when executionPermissions hooks are provided", async () => { + const { wallet, handlers } = createMockWallet(); + const granted = [ + { + chainId: SEPOLIA, + to: EVMAccountAddress("0x0000000000000000000000000000000000000001"), + permission: { + type: "erc20-token-periodic", + isAdjustmentAllowed: true, + data: {}, + }, + context: HexString("0xabc"), + dependencies: [], + delegationManager: EVMAccountAddress( + "0x0000000000000000000000000000000000000002", + ), + }, + ]; + const supported = { + "erc20-token-periodic": { + chainIds: [SEPOLIA], + ruleTypes: [] as string[], + }, + }; + + new RpcHelper( + new Map([[SEPOLIA, "https://example.invalid"]]), + wallet, + null, + { + executionPermissions: { + requestExecutionPermissions: async (requests) => { + assert.equal(requests.length, 1); + return granted; + }, + revokeExecutionPermission: async (params) => { + assert.equal(params.permissionContext, "0xdead"); + return null; + }, + getSupportedExecutionPermissions: async () => supported, + getGrantedExecutionPermissions: async () => granted, + }, + }, + ); + + assert.equal( + typeof handlers.get("wallet_requestExecutionPermissions"), + "function", + ); + assert.equal( + typeof handlers.get("wallet_revokeExecutionPermission"), + "function", + ); + assert.equal( + typeof handlers.get("wallet_getSupportedExecutionPermissions"), + "function", + ); + assert.equal( + typeof handlers.get("wallet_getGrantedExecutionPermissions"), + "function", + ); + + const requestResult = await handlers.get( + "wallet_requestExecutionPermissions", + )!([ + { + chainId: SEPOLIA, + to: EVMAccountAddress("0x0000000000000000000000000000000000000001"), + permission: { + type: "erc20-token-periodic", + isAdjustmentAllowed: true, + data: {}, + }, + }, + ]); + assert.deepEqual(requestResult, granted); + + assert.equal( + await handlers.get("wallet_revokeExecutionPermission")!([ + { permissionContext: HexString("0xdead") }, + ]), + null, + ); + assert.deepEqual( + await handlers.get("wallet_getSupportedExecutionPermissions")!([]), + supported, + ); + assert.deepEqual( + await handlers.get("wallet_getGrantedExecutionPermissions")!([]), + granted, + ); + }); +}); diff --git a/packages/ows-wallet-utils/test/scaffold.test.ts b/packages/ows-wallet-utils/test/scaffold.test.ts new file mode 100644 index 0000000..2333dfc --- /dev/null +++ b/packages/ows-wallet-utils/test/scaffold.test.ts @@ -0,0 +1,10 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { OWSWallet } from "../src/index.ts"; + +describe("@1shotapi/ows-wallet-utils exports", () => { + it("exports OWSWallet factory", () => { + assert.equal(typeof OWSWallet.create, "function"); + assert.equal(typeof OWSWallet.prepare, "function"); + }); +}); diff --git a/packages/ows-wallet-utils/tsconfig.json b/packages/ows-wallet-utils/tsconfig.json new file mode 100644 index 0000000..d037e61 --- /dev/null +++ b/packages/ows-wallet-utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"] +} diff --git a/spec/credentials/README.md b/spec/credentials/README.md new file mode 100644 index 0000000..1d74807 --- /dev/null +++ b/spec/credentials/README.md @@ -0,0 +1,70 @@ +# OWS Credentials Extension + +Standards-aligned verifiable credential support for Open Wallet Standard (OWS) wallets. OWS does **not** invent a new identity system — it exposes existing standards through a dedicated `proxy.credentials` / `wallet.credentials` provider boundary. + +## Standards alignment + +| Standard | Role in OWS | +|----------|-------------| +| [W3C Verifiable Credentials Data Model 2.0](https://www.w3.org/TR/vc-data-model-2.0/) | Semantic model for credentials (issuer, subject, validity, status) | +| [OpenID for Verifiable Credential Issuance (OID4VCI)](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) | Issuer → wallet flow | +| [OpenID for Verifiable Presentations (OID4VP)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | Verifier → wallet → verifier flow | +| SD-JWT VC (and pluggable formats) | Selective disclosure for privacy-preserving presentations | + +## Layering + +``` +OWS Credentials Extension + ├── Credential storage / listing / deletion + ├── OID4VCI client abstraction (issuance) + ├── OID4VP client abstraction (presentation) + ├── Consent UI (app-owned Branding Layer) + ├── Status / revocation validation hooks + └── Issuer trust metadata hooks + +Optional OWS KYC Profile (see kyc-profile.md) + └── Recommended schemas, trust framework, assurance levels — a profile on VC/OID4VC, not a new credential standard +``` + +## Provider boundary + +Credentials use a **dedicated namespace**, separate from EIP-1193 (`proxy.ethereum`) and generic custom RPC (`proxy.rpc`): + +- **Host:** `proxy.credentials.acceptOffer()`, `.present()`, `.list()`, `.delete()` +- **Branding:** `wallet.credentials.register({ acceptOffer, present, list, delete })` +- **Wire:** Postmate keys prefixed with `credentials.` (e.g. `credentials.acceptOffer`) + +Host apps must **not** call `proxy.rpc()` for credential operations. + +## Actors + +| Actor | Description | +|-------|-------------| +| **Issuer** | Signs and issues credentials (KYC provider, bank, employer, …) | +| **Holder** | The user | +| **Wallet** | OWS-compatible branding layer — stores credentials, mediates consent, presents claims | +| **Verifier** | Requests proof (dApp, neobank, agent, …) | + +## Non-goals (this reference implementation) + +- Proprietary identity product +- Full production VC wallet +- Sensitive KYC/PII onchain (onchain data limited to status, revocation, nullifiers if any) +- Real OID4VCI/OID4VP HTTP servers in the stub phase (mocks only) + +## Package map + +| Path | Purpose | +|------|---------| +| `spec/credentials/` | Normative extension docs | +| `packages/ows-types` | Credential types, OID4 client interfaces, SD-JWT VC utils | +| `packages/ows-wallet-utils` | Branding wire + holder signer bridge | +| `packages/ows-provider` | Host credentials proxy + SD-JWT verify | +| `examples/general-wallet` | Branding demo — credentials wire + consent UI | +| `examples/shared` | Demo mocks (not published) | +| `examples/credential-issuer` | Host demo — mock issuance | +| `examples/credential-verifier` | Host demo — mock verification | + +## Future integration (post-stub) + +See [roadmap.md](./roadmap.md) for phased delivery. Real OID4VCI/OID4VP HTTP and encrypted credential storage are swappable behind the interfaces in `@1shotapi/ows-types`. Demo mocks live under `examples/shared/`. diff --git a/spec/credentials/kyc-profile.md b/spec/credentials/kyc-profile.md new file mode 100644 index 0000000..5b7fda7 --- /dev/null +++ b/spec/credentials/kyc-profile.md @@ -0,0 +1,73 @@ +# OWS KYC Profile + +An **optional profile** layered on W3C VC + OID4VC standards. It is not a new credential standard — it recommends schemas, trust metadata, and verifier policies for reusable KYC credentials. + +## Recommended credential type + +```json +{ + "type": ["VerifiableCredential", "KycCredential"], + "credentialSchema": { + "id": "https://schemas.ows.example/kyc/v1", + "type": "JsonSchema" + } +} +``` + +## Recommended claims (credentialSubject) + +| Claim | Type | Notes | +|-------|------|-------| +| `ageOver18` | boolean | Selective disclosure friendly | +| `country` | string (ISO 3166-1 alpha-2) | Jurisdiction | +| `assuranceLevel` | enum | `low` \| `substantial` \| `high` | +| `verifiedAt` | ISO 8601 datetime | Freshness input | + +Avoid placing full legal name, government ID numbers, or biometrics in default reusable credentials. Verifiers request minimum necessary claims via presentation definitions. + +## Trust framework + +`IssuerTrustMetadata` (in `@1shotapi/ows-types`) describes issuers the wallet or verifier may trust: + +```json +{ + "issuerId": "https://kyc.demo.issuer.example", + "name": "Demo KYC Issuer", + "assuranceLevels": ["substantial", "high"], + "jurisdictions": ["US", "GB"] +} +``` + +Wallets may surface issuer name during consent. Verifiers maintain a host-side allow-list (`acceptedIssuers` on `present`) and `KycProfilePolicy`; wallets maintain an `IIssuerTrustRegistry`: + +- Allowed issuer IDs +- Maximum credential age (`maxAgeDays`) +- Required claims for a given use case +- Minimum assurance level + +## Presentation policy example + +```ts +const policy: KycProfilePolicy = { + requiredClaims: ["ageOver18", "country"], + minAssuranceLevel: "substantial", + maxAgeDays: 365, + allowedIssuers: ["https://kyc.demo.issuer.example"], +}; +``` + +## Compliance nuance + +A reusable KYC credential proves that **an issuer made a claim** at a point in time. It does **not** automatically satisfy every verifier's legal obligations. Verifiers must still decide: + +- Whether they trust the issuer +- What verification process the issuer performed +- Credential freshness and jurisdiction +- Whether sanctions screening is current +- Liability allocation + +OWS separates **credential transport and storage** (wallet extension) from **verifier compliance policy** (`KycProfilePolicy` and app-specific rules). + +## Wallet / account binding + +Future profiles may bind credentials to wallet keys (e.g. holder binding JWT signed via `ows-signer`). Not required in the stub phase. diff --git a/spec/credentials/oid4vci-flow.md b/spec/credentials/oid4vci-flow.md new file mode 100644 index 0000000..169ef0c --- /dev/null +++ b/spec/credentials/oid4vci-flow.md @@ -0,0 +1,72 @@ +# OID4VCI flow — Issuer → Wallet + +Maps the [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) pattern to OWS abstractions. + +- **Production / demos:** `@1shotapi/ows-oid4` (`HttpOid4vciClient` + `CredentialsHelper`) +- **Unit tests:** optional `MockOid4vciClient` in `examples/shared` +- **DI seam:** `IOid4vciClient` in `@1shotapi/ows-types` +- **Wire hooks:** `wallet.credentials` registrar in `@1shotapi/ows-wallet-utils` + +## Sequence + +```mermaid +sequenceDiagram + participant Issuer + participant Host as proxy.credentials + participant Wallet as wallet.credentials + participant Holder as IHolderSigner + participant Vci as IOid4vciClient + participant Store as ICredentialRepository + + Issuer->>Host: acceptOffer(credentialOfferUri) + Host->>Wallet: credentials.acceptOffer + Wallet->>Vci: resolveOffer(uri) + Vci-->>Wallet: CredentialOffer + Wallet->>Vci: fetchIssuerMetadata(issuer) + Vci-->>Wallet: IssuerMetadata + Note over Wallet: consent UI + Wallet->>Vci: prepareCredentialRequest (pre-authorized token + c_nonce) + Wallet->>Holder: publicKeyJwk + sign OID4VCI proof JWT + Wallet->>Vci: requestCredential(offer, metadata, context) + Note over Vci: verify proof; embed cnf.jwk + Vci-->>Wallet: credential payload + Wallet->>Store: save(storedCredential) + Wallet-->>Host: CredentialReceipt +``` + +## `IOid4vciClient` interface + +| Method | OID4VCI step | +|--------|----------------| +| `resolveOffer(uri)` | Parse credential offer (`https://…`, `openid-credential-offer://`) | +| `fetchIssuerMetadata(issuer)` | `/.well-known/openid-credential-issuer` | +| `prepareCredentialRequest?(offer, metadata)` | Pre-authorized token exchange → access token + `c_nonce` | +| `requestCredential(offer, metadata, context)` | Credential endpoint with holder PoP (+ optional wallet attestation) | + +## Grant profile (Phase 4) + +- **Supported:** `urn:ietf:params:oauth:grant-type:pre-authorized_code` only +- **Deferred:** authorization-code + PKCE / full authorization server + +`CredentialsHelper` prefers `c_nonce` from `prepareCredentialRequest`; `getProofNonce` remains an optional fallback for mocks. + +## Holder binding (proof-of-possession) + +`CredentialIssuanceContext` carries: + +- `holderPublicKeyJwk` — key embedded as SD-JWT VC `cnf.jwk` after verification +- `proof` — OID4VCI JWT proof (`proof_type: "jwt"`, `typ: openid4vci-proof+jwt`) +- optional `nonce` — issuer C-nonce echoed in the proof +- optional `walletAttestationJwt` — when the issuer profile requires attestation + +Helpers: `ProofUtils.buildOid4vciProofJwt` / `ProofUtils.verifyOid4vciProofJwt` in `@1shotapi/ows-types`. + +## Entry point + +```ts +await proxy.credentials.acceptOffer({ + credentialOfferUri: CredentialOfferUri("https://issuer.example/offers/demo"), +}); +``` + +Local demos: `examples/credential-issuer` serves `/.well-known/openid-credential-issuer`, `/token`, `/credential`, and `/offers/demo`. diff --git a/spec/credentials/oid4vp-flow.md b/spec/credentials/oid4vp-flow.md new file mode 100644 index 0000000..805703d --- /dev/null +++ b/spec/credentials/oid4vp-flow.md @@ -0,0 +1,81 @@ +# OID4VP flow — Verifier → Wallet → Verifier + +Maps the [OpenID for Verifiable Presentations](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) pattern to OWS abstractions. + +- **Production / demos:** `@1shotapi/ows-oid4` (`HttpOid4vpClient` + `CredentialsHelper`) +- **Unit tests:** optional `MockOid4vpClient` in `examples/shared` +- **DI seam:** `IOid4vpClient` in `@1shotapi/ows-types` +- **Wire hooks:** `wallet.credentials` registrar in `@1shotapi/ows-wallet-utils` + +## Sequence + +```mermaid +sequenceDiagram + participant Verifier + participant Host as proxy.credentials + participant Wallet as wallet.credentials + participant Vp as IOid4vpClient + participant UI as credential_consent + participant Store as ICredentialRepository + + Verifier->>Host: present(requestUri) + Host->>Wallet: credentials.present + Wallet->>Vp: resolveRequest(uri) + Vp-->>Wallet: PresentationDefinition + Wallet->>Store: list(matching filter) + Wallet->>Vp: matchCredentials(definition, credentials) + Wallet->>UI: requestCredentialPresentationApproval + UI-->>Wallet: user approved + Wallet->>Vp: buildPresentation(credential, definition) + Note over Vp: optional attestation; encrypt; POST response_uri + Vp-->>Wallet: PresentationResult + Wallet-->>Host: presentation + Verifier->>Verifier: validate (JWKS + policy + custody) +``` + +## `IOid4vpClient` interface + +| Method | OID4VP step | +|--------|-------------| +| `resolveRequest(uri)` | Fetch `request_uri` (JSON); map DCQL or claim-list PD | +| `getAuthorizationRequest?(id)` | Raw auth request (response_uri, encryption JWKS, DCQL) | +| `matchCredentials(definition, credentials)` | Find satisfying credentials | +| `buildPresentation(credential, definition, context)` | SD-JWT VC + kb+jwt; optional JWE + `direct_post` | + +## DCQL subset (Phase 4) + +Supported: + +- Single credential query (`credentials.length === 1`) +- `meta.vct_values` → OWS `credentialTypes` +- Claim `path` entries → OWS `requestedClaims` (last path segment) + +Unsupported constructs fail closed (reject). Full PE 2.0 / multi-credential presentations are deferred. + +## Response modes + +| Mode | Behavior | +|------|----------| +| `direct_post` | POST `vp_token` + `presentation_submission` to `response_uri` | +| `direct_post.jwt` | Encrypt VP with verifier JWKS (`ECDH-ES` + `A256GCM` by default); POST `response` | + +`PresentationResult` still returns plaintext `presentation` for host custody UI when available, plus optional `encryptedResponse` / `responseMode` / `submittedToResponseUri`. + +## Wallet attestation + +When `client_metadata.require_wallet_attestation` is set, `HttpOid4vpClient` invokes `IWalletAttestationProvider` (demo profile only — not production trust roots). + +## Consent + +Before `buildPresentation`, the wallet must show the verifier identity and requested claims via `requestCredentialPresentationApproval`. + +## Entry point + +```ts +const result = await proxy.credentials.present({ + requestUri: PresentationRequestUri("https://verifier.example/request/demo"), + acceptedIssuers: [CredentialIssuer("https://issuer.example")], +}); +``` + +Local demos: `examples/credential-verifier` serves `/request/demo` (DCQL + encryption JWKS), `/response`, and `/issuer-jwks`. diff --git a/spec/credentials/provider-methods.md b/spec/credentials/provider-methods.md new file mode 100644 index 0000000..dd14a79 --- /dev/null +++ b/spec/credentials/provider-methods.md @@ -0,0 +1,171 @@ +# OWS Credentials — Provider methods + +Credential operations are exposed only through the **`credentials` namespace**. They do not use generic `proxy.rpc()` or `wallet.registerRpc()`. + +## Host API (`proxy.credentials`) + +```ts +const proxy = await OWSProxy.create(container, walletUrl); + +await proxy.credentials.acceptOffer(input); +await proxy.credentials.present(input); +await proxy.credentials.list(filter?); +await proxy.credentials.delete(input); +``` + +Branding registers handlers on `wallet.credentials` (prefer `CredentialsHelper` from `@1shotapi/ows-oid4`). Handlers must be registered **before** `wallet.start()`: + +```ts +import { + CredentialsHelper, + createCredentialsHolderSigner, + issueCredentialAfterApproval, + presentCredentialAfterApproval, +} from "@1shotapi/ows-oid4"; + +const resolveHolderSigner = createCredentialsHolderSigner(signer); + +const helper = new CredentialsHelper(wallet, { + repository, + oid4vci, + oid4vp, + trust, + approveAndAcceptOffer: async (request) => { + // setup / consent UI / passkey — branding-owned + return issueCredentialAfterApproval({ + offer: request.offer, + metadata: request.metadata, + oid4vci, + repository, + resolveHolderSigner, + getProofNonce: async () => nonceFromIssuer, + }); + }, + approveAndPresent: async (request) => { + return presentCredentialAfterApproval({ + definition: request.definition, + credential: request.credential, + oid4vp, + resolveHolderSigner, + }); + }, +}); +helper.register(); +``` + +Or register an `OpenWalletCredentialProvider` directly: + +```ts +wallet.credentials.register({ + acceptOffer: async (input) => { /* … */ }, + present: async (input) => { /* … */ }, + list: async (filter) => { /* … */ }, + delete: async (input) => { /* … */ }, +}); +``` + +## Wire transport + +Calls use the standard OWS `RpcRequestEnvelope` / `ows:rpcCallback` protocol. Postmate model keys are **namespaced**: + +| Public method | Wire key | +|---------------|----------| +| `acceptOffer` | `credentials.acceptOffer` | +| `present` | `credentials.present` | +| `list` | `credentials.list` | +| `delete` | `credentials.delete` | + +## Methods + +### `acceptOffer` + +OID4VCI-style credential issuance (stub accepts mock or inline offers). + +**Input:** + +```json +{ + "credentialOfferUri": "mock://kyc-offer/demo", + "offer": { } +} +``` + +Either `credentialOfferUri` or inline `offer` may be provided. + +**Result:** + +```json +{ + "credentialId": "cred_mock_kyc_001", + "format": "sd-jwt-vc", + "type": ["VerifiableCredential", "KycCredential"] +} +``` + +### `present` + +OID4VP-style presentation to a verifier. + +**Input:** + +```json +{ + "requestUri": "mock://kyc-presentation/demo", + "request": { }, + "acceptedIssuers": ["https://kyc.demo.issuer.example"] +} +``` + +Either `requestUri` or inline `request` may be provided. Optional `acceptedIssuers` is a host/verifier allow-list; the branding layer intersects it with wallet matches before consent. + +**Result:** + +```json +{ + "presentation": "MOCK_PRESENTATION_BLOB", + "format": "sd-jwt-vc", + "disclosedClaims": ["ageOver18", "country"] +} +``` + +### `list` + +Returns credential summaries (no full credential payloads by default). + +**Input (optional):** + +```json +{ + "type": "KycCredential", + "issuer": "https://issuer.example" +} +``` + +**Result:** array of `CredentialSummary`. + +### `delete` + +Removes a stored credential. + +**Input:** + +```json +{ + "credentialId": "cred_mock_kyc_001" +} +``` + +**Result:** `null` (void). + +## Errors + +| Code | Meaning | +|------|---------| +| `4001` | User rejected consent | +| `-32602` | Invalid params | +| `-32601` | Credentials namespace not registered | +| `-32603` | Internal / mock flow error | + +## Display lifecycle + +Interactive methods (`acceptOffer`, `present`) should call `wallet.requestDisplay()` before WebAuthn or consent UI, and release the display session when done. The host auto-focuses the wallet iframe on RPC; explicit display is still recommended for cross-origin embeds. diff --git a/spec/credentials/roadmap.md b/spec/credentials/roadmap.md new file mode 100644 index 0000000..8b9ab00 --- /dev/null +++ b/spec/credentials/roadmap.md @@ -0,0 +1,110 @@ +# OWS Credentials Extension — Roadmap + +This document tracks implementation phases for the OWS Credentials Extension. Normative wire contracts live in the sibling docs (`provider-methods.md`, `oid4vci-flow.md`, `oid4vp-flow.md`). + +## Architecture principles + +| Layer | Responsibility | +|-------|----------------| +| **Branding Layer** | Owns `ICredentialRepository` — store, get, list, delete, revoke (local). Storage may be client-side (IndexedDB, `localStorage`), server-backed, or hybrid. Mediates consent UI and `IIssuerTrustRegistry`. | +| **`@1shotapi/ows-types`** | Credential wire contracts, domain types, OID4VCI/OID4VP client interfaces, status/trust hooks, SD-JWT VC presentation/crypto utils. | +| **`@1shotapi/ows-wallet-utils`** | Zod param schemas, `wallet.credentials` registrar (hooks). | +| **`@1shotapi/ows-oid4`** | Optional credentials add-on: `CredentialsHelper`, HTTP OID4 clients, holder signer bridge. | +| **`@1shotapi/ows-provider`** | `proxy.credentials` host client, SD-JWT VC presentation verification. | +| **Host Layer** | Calls `proxy.credentials.*` (may pass `acceptedIssuers` on present); never touches credential payloads or signing keys directly. | +| **Signing Layer** | Signs digests/curves; PRF-derived AES seal/unseal (`encryptAES256` / `decryptAES256`). JWT assembly stays in `ows-types` / `ows-signer-utils`. | + +Branding registers handlers on `wallet.credentials` (typically via `CredentialsHelper` from `ows-oid4`). The library builds presentations; branding fetches the stored credential and supplies a `IHolderSigner` adapter wired to the OWS signer. + +--- + +## Phase 1 — Real SD-JWT VC presentations + +**Goal:** Replace mock presentation blobs with standards-compliant SD-JWT VC selective disclosure and holder key binding (`kb+jwt`). + +**Library:** [OpenWallet Foundation `@sd-jwt/core` + `@sd-jwt/sd-jwt-vc`](https://github.com/openwallet-foundation/sd-jwt-js) (RFC 9901, SD-JWT VC draft). + +**Deliverables:** + +- [x] `IHolderSigner` interface and `PresentationUtils` in `@1shotapi/ows-types` +- [x] Mock OID4VCI issues real SD-JWT VCs (demo issuer key; `cnf.jwk` binds holder) +- [x] Mock OID4VP builds real presentations with selective disclosure + `kb+jwt` +- [x] `CredentialCryptoUtils.createOwsEd25519HolderSigner()` bridges Signing Layer digests → `IHolderSigner` (`ows-types`) +- [x] Verifier demo validates presentations cryptographically (not string-prefix checks) + +**Out of scope for Phase 1:** Real HTTP OID4VCI/OID4VP, issuance PoP at token endpoint, status list fetching. + +--- + +## Phase 2 — Holder binding at issuance (OID4VCI PoP) + +**Goal:** Bind credentials to the wallet key at issuance time per OID4VCI (`proof` / `cnf` in token request). + +- [x] Extend `IOid4vciClient.requestCredential` with wallet-provided holder key material + JWT proof +- [x] Mock issuer verifies OID4VCI proof before embedding matching `cnf.jwk` +- [x] Live wallet path (`CredentialsHelper` in `ows-oid4` / `registerCredentialsProvider`) builds proof via `IHolderSigner` (no silent demo-holder fallback) +- [x] Verifier demo inspects disclosed claims and highlights issuer → `cnf` → `kb+jwt` custody + +**Out of scope for Phase 2:** Real HTTP OID4VCI token endpoint (Phase 4); multi-credential presentations. + +--- + +## Phase 3 — Production wallet hardening (interfaces first) + +- [x] `ICredentialRepository` (full `StoredCredential` JSON at the API boundary; branding owns encrypt-at-rest) +- [x] `IIssuerTrustRegistry` with `isTrustedIssuer` (+ resolve/list for consent copy) +- [x] Host `present.acceptedIssuers` allow-list intersected with wallet matches +- [x] `CredentialsHelper` in `@1shotapi/ows-oid4` (consent **before** PoP; fail-closed status) +- [x] `ICredentialStatusValidator` rename; demo noop; fail-closed in helper +- [x] Forward-declared `AES256CipherText` + `OWSSigner.encryptAES256` / `decryptAES256` (stubs → `notImplemented`) + +### Phase 3.1 — Signer KMS seal + +- [x] PRF-derived `encryptAES256` / `decryptAES256` (HKDF from secp256k1 scalar, `ows-aes1:` envelope, batch, recovery-session path) + +### Deferred (later phases / follow-ons) + +| Item | Target | +|------|--------| +| Branding encrypt-at-rest using those APIs (1Shot: batch decrypt on first use, session memory) | Consumer app — not general-wallet | +| `@owf/token-status-list` (or equiv.) behind `ICredentialStatusValidator` | **Phase 3.2** — real status / revocation | +| PRF / key-material catastrophe or migrate-or-rewrap | Later investigation (catastrophic; out of V3 scope) | + +--- + +## Phase 4 — Real OID4VCI / OID4VP HTTP clients + +- [x] `@1shotapi/ows-oid4`: optional credentials add-on — `CredentialsHelper`, `HttpOid4vciClient` / `HttpOid4vpClient` (fetch-only) +- [x] Pre-authorized_code token + credential endpoints; client-discovered `c_nonce` +- [x] DCQL subset → OWS `PresentationDefinition`; `request_uri` HTTPS JSON +- [x] Wallet attestation hooks (`IWalletAttestationProvider`; demo profile) +- [x] Response encryption (`direct_post.jwt`, ECDH-ES + A256GCM) when verifier requests it +- [x] Local HTTP demos: `examples/credential-issuer` / `examples/credential-verifier` +- [x] `general-wallet` wires HTTP clients (mocks remain for unit tests only) + +**Deferred from Phase 4:** auth-code + PKCE, arbitrary DCQL / PE 2.0, production attestation trust roots, multi-credential presentations. + +## Phase 5 — Credential delegation (holder-signed grants) + +**Preferred model (Model A):** Holder-signed **delegation grant** — scoped, time-bound authorization for a delegatee key to present specific credentials/claims. Not on-chain ERC-7710 delegation for v1. + +- Spec: `spec/credentials/delegation.md` +- New `proxy.credentials` / `wallet.credentials` methods for grant create/revoke/list +- Consent UI for delegation approval +- v1 signing: Ed25519 via existing OWS signer (`ed25519` scheme); ES256/P-256 only if issuers require + +--- + +## Package map (reference) + +| Path | Role | +|------|------| +| `packages/ows-types/src/credentials/` | Domain types, OID4 client interfaces | +| `packages/ows-types/src/utils/credentials/` | SD-JWT VC presentation, crypto, and OID4 PoP helpers | +| `packages/ows-oid4/` | Optional credentials add-on: helper, HTTP OID4 clients, DCQL mapper, response encrypt | +| `packages/ows-wallet-utils/src/credentials/` | Wire schemas + `CredentialWalletRegistrar` hooks | +| `packages/ows-provider/src/credentials/` | Host client, SD-JWT verify | +| `examples/general-wallet/src/ows/registerCredentialsProvider.ts` | Demo wire: repo + `ows-oid4` helper + trust | +| `examples/shared/` | Demo mocks / fixtures (unit tests; not published) | +| `examples/credential-issuer` | Host demo — HTTP OID4VCI issuer | +| `examples/credential-verifier` | Host demo — HTTP OID4VP verifier + custody UI | diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ab10f2d --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +}