diff --git a/.agents/skills/oneshot-embedded-wallet/SKILL.md b/.agents/skills/oneshot-embedded-wallet/SKILL.md new file mode 100644 index 0000000..baf2d5c --- /dev/null +++ b/.agents/skills/oneshot-embedded-wallet/SKILL.md @@ -0,0 +1,239 @@ +--- +name: oneshot-embedded-wallet +description: >- + Integrate the 1Shot embedded wallet (OWS Host Layer) with @1shotapi/ows-provider. + Use when embedding wallet.1shotapi.com, wiring OWSProxy, EIP-1193, credentials, + or custom RPC such as setStyle / focusWallet / addAsset for theming, host-driven focus mode, and tracked assets on the 1Shot Branding Layer. +license: MIT +metadata: + author: 1Shot-API + version: "0.1.0" + repository: https://github.com/1Shot-API/embedded-wallet +--- + +# 1Shot Embedded Wallet (Host integration) + +Teach an agent how to embed the **1Shot Wallet** Branding Layer from a Host Layer app using `@1shotapi/ows-provider`. + +``` +Host (your dapp) @1shotapi/ows-provider → OWSProxy + └── Branding iframe https://wallet.1shotapi.com/ + └── Signing https://wallet.1shotapi.com/signer/ (same origin) +``` + +## Install + +```bash +npm install @1shotapi/ows-provider @1shotapi/ows-types +``` + +## Minimal setup + +```typescript +import { OWSProxy } from "@1shotapi/ows-provider"; + +const WALLET_URL = "https://wallet.1shotapi.com/"; + +const container = document.getElementById("wallet-container")!; +const proxy = await OWSProxy.create(container, WALLET_URL); + +// Optional: theme / copy before showing the flyout +await proxy.rpc("setStyle", { + copy: { productName: "Acme Wallet", tagline: "Powered by 1Shot" }, + theme: { primary: "oklch(0.45 0.18 250)" }, +}); + +proxy.showWallet(); + +// EIP-1193 +const accounts = await proxy.ethereum.request({ method: "eth_requestAccounts" }); +``` + +### Local / HTTPS notes + +- Passkeys require a **secure-context ancestor chain**. The Host page must be HTTPS (or `localhost`) when the wallet iframe is HTTPS. +- Dev wallet URL: your ngrok or local Vite origin root (e.g. `https://….ngrok-free.app/`). +- Production wallet URL: **`https://wallet.1shotapi.com/`** (Signing Layer at `/signer/` on the same origin — do not embed `/signer/` from the host). + +## Custom RPC — `setStyle` + +1Shot-specific method registered on the Branding Layer. Call via: + +```typescript +await proxy.rpc("setStyle", options); +``` + +`options` is a partial merge (safe to call repeatedly): + +| Field | Type | Purpose | +|-------|------|---------| +| `theme.primary` | string (CSS color) | `--primary` | +| `theme.primaryForeground` | string | `--primary-foreground` | +| `theme.background` / `foreground` | string | page colors | +| `theme.muted` / `mutedForeground` | string | secondary text | +| `theme.border` / `accent` / `accentForeground` | string | chrome | +| `theme.radius` | string | `--radius` (e.g. `"0.625rem"`) | +| `theme.fontSans` | string | `--font-sans` | +| `copy.productName` | string | titles / chrome | +| `copy.tagline` | string | supporting line | +| `copy.connect.title` | string | connect modal title | +| `copy.connect.body` | string | connect modal body | +| `copy.connect.rejectLabel` | string | Reject button | +| `copy.connect.continueLabel` | string | Continue button | +| `copy.walletSetup.title` | string | setup modal title | +| `copy.walletSetup.body` | string | setup modal body | +| `copy.walletSetup.cancelLabel` | string | Cancel button | +| `copy.walletSetup.loginLabel` | string | Login with passkey | +| `copy.walletSetup.createLabel` | string | Create account | +| `copy.passkeyName.title` | string | passkey name modal title | +| `copy.passkeyName.body` | string | passkey name modal body | +| `copy.passkeyName.fieldLabel` | string | input label | +| `copy.passkeyName.placeholder` | string | input placeholder | +| `copy.passkeyName.emptyError` | string | empty-name validation error | +| `copy.passkeyName.cancelLabel` | string | Cancel button | +| `copy.passkeyName.continueLabel` | string | Continue button | +| `copy.personalSign.title` | string | personal_sign modal title | +| `copy.personalSign.accountLabel` | string | Account field label | +| `copy.personalSign.messageLabel` | string | Message field label | +| `copy.personalSign.rejectLabel` | string | Reject button | +| `copy.personalSign.signLabel` | string | Sign button | +| `copy.typedData.title` | string | EIP-712 modal title | +| `copy.typedData.accountLabel` | string | Account field label | +| `copy.typedData.primaryTypeLabel` | string | Primary type label | +| `copy.typedData.domainLabel` | string | Domain label | +| `copy.typedData.messageLabel` | string | Message label | +| `copy.typedData.rejectLabel` | string | Reject button | +| `copy.typedData.signLabel` | string | Sign button | +| `copy.credentialOffer.title` | string | offer modal title | +| `copy.credentialOffer.body` | string | supports `{issuerName}` `{issuerId}` | +| `copy.credentialOffer.offeredHeading` | string | offered list heading | +| `copy.credentialOffer.passkeyNote` | string | passkey hint | +| `copy.credentialOffer.rejectLabel` | string | Reject button | +| `copy.credentialOffer.acceptLabel` | string | Accept button | +| `copy.credentialPresentation.title` | string | presentation modal title | +| `copy.credentialPresentation.body` | string | supports `{verifierName}` `{verifierId}` | +| `copy.credentialPresentation.credentialDetail` | string | supports `{credentialType}` `{credentialIssuer}` | +| `copy.credentialPresentation.claimsHeading` | string | claims list heading | +| `copy.credentialPresentation.passkeyNote` | string | passkey hint | +| `copy.credentialPresentation.rejectLabel` | string | Reject button | +| `copy.credentialPresentation.shareLabel` | string | Share button | +| `copy.credentials.tabLabel` | string | Credentials tab label | +| `copy.credentials.emptyCountLabel` | string | zero-count summary | +| `copy.credentials.countLabel` | string | supports `{count}` | +| `copy.credentials.refreshLabel` | string | Refresh button | +| `copy.credentials.loadingBody` | string | loading state | +| `copy.credentials.emptyBody` | string | empty-state text | +| `copy.credentials.loadFailedError` | string | list load failure | +| `copy.credentials.refreshFailedError` | string | relayer refresh failure | +| `copy.credentials.notFoundError` | string | detail not in cache | +| `copy.credentials.openFailedError` | string | detail open failure | +| `copy.credentials.typeColumn` | string | Type column header | +| `copy.credentials.issuerColumn` | string | Issuer column header | +| `copy.credentials.issuedColumn` | string | Issued column header | +| `copy.credentials.viewLabel` | string | View button | +| `copy.credentials.detailFallbackTitle` | string | detail title fallback | +| `copy.credentials.detailDescription` | string | detail dialog description | +| `copy.credentials.issuerLabel` | string | Issuer field label | +| `copy.credentials.formatLabel` | string | Format field label | +| `copy.credentials.issuedLabel` | string | Issued field label | +| `copy.credentials.validUntilLabel` | string | Valid until label | +| `copy.credentials.idLabel` | string | Id field label | +| `copy.credentials.claimsHeading` | string | Claims section heading | +| `copy.credentials.claimsLoading` | string | claims loading text | +| `copy.credentials.claimsEmpty` | string | no claims text | +| `copy.credentials.closeLabel` | string | Close button | +| `copy.createBackup.title` | string | create backup modal title | +| `copy.createBackup.body` | string | supports `{minLength}` | +| `copy.createBackup.passphrasePrompt` | string | Signing Layer passphrase label (`{minLength}`) | +| `copy.createBackup.continueLabel` | string | Signing Layer continue | +| `copy.createBackup.cancelLabel` | string | Cancel button | +| `copy.createBackup.closeLabel` | string | Close button | +| `copy.createBackup.copyLabel` | string | Copy button | +| `copy.createBackup.copiedLabel` | string | after successful copy | +| `copy.createBackup.copyFailedLabel` | string | copy failure | +| `copy.createBackup.doneLabel` | string | Done button | +| `copy.createBackup.encryptedLabel` | string | result ciphertext label | +| `copy.createBackup.passwordTooShortError` | string | short passphrase error | +| `copy.createBackup.cancelledError` | string | passkey cancelled | +| `copy.createBackup.failedError` | string | generic failure | +| `copy.restoreBackup.title` | string | restore backup modal title | +| `copy.restoreBackup.body` | string | restore prompt body | +| `copy.restoreBackup.passphraseLabel` | string | Signing Layer passphrase label | +| `copy.restoreBackup.restoreLabel` | string | Signing Layer restore button | +| `copy.restoreBackup.cancelLabel` | string | Cancel button | +| `copy.restoreBackup.closeLabel` | string | Close button | +| `copy.restoreBackup.doneLabel` | string | Done button | +| `copy.restoreBackup.successBody` | string | success message | +| `copy.restoreBackup.decryptFailedError` | string | bad passphrase error | +| `copy.restoreBackup.cancelledError` | string | passkey cancelled | +| `copy.restoreBackup.failedError` | string | generic failure | +| `dark` | boolean | toggles `html.dark` | + +Returns `{ ok: true, productName: string }` with the resolved product name after merge. + +Unknown keys are rejected (Zod `.strict()`). + +See also [README.md](../../README.md) in this repository. + +## Custom RPC — `focusWallet` / `unfocusWallet` + +Host-controlled shell modes. Callers (not end users) switch between **General** (multi-chain tabs) and **Focused** (single chain + asset detail view). + +```typescript +// Lock to one chain + ERC-20 (or other) asset +await proxy.rpc("focusWallet", { + chainId: "0x4cef52", // Arc Testnet + assetAddress: "0x3600000000000000000000000000000000000000", // USDC +}); +proxy.showWallet(); + +// Restore general mode (keeps the current chain) +await proxy.rpc("unfocusWallet"); +``` + +| Method | Params | Effect | +|--------|--------|--------| +| `focusWallet` | `{ chainId: \`0x…\`, assetAddress: \`0x…\` }` | Switches active chain, sets focused asset, shows Asset Details shell | +| `unfocusWallet` | none | Clears focus; returns to network selector + tabs | + +`focusWallet` returns `{ ok: true, mode: "focused", chainId, assetAddress }`. +`unfocusWallet` returns `{ ok: true, mode: "general" }`. + +Unlike `addAsset`, **`focusWallet` does not ask the user for confirmation** — hosts may temporarily lock the shell to any asset. + +## Custom RPC — `addAsset` + +Propose a tracked **ERC-20** for the Balances tab. The wallet resolves the token (known catalog, or on-chain `getCode` + `name`/`symbol`/`decimals`) **before** showing the confirm modal. Non-ERC-20 addresses are rejected. **Always requires user confirmation** (Reject / Add). On approval the asset is persisted; on rejection the RPC throws a user-rejected error. + +```typescript +await proxy.rpc("addAsset", { + chainId: "0x4cef52", // Arc Testnet + assetAddress: "0x3600000000000000000000000000000000000000", // USDC +}); +proxy.showWallet(); +``` + +| Method | Params | Effect | +|--------|--------|--------| +| `addAsset` | `{ chainId: \`0x…\`, assetAddress: \`0x…\` }` | Probes ERC-20, shows confirm modal; on accept, adds to tracked assets | + +Returns `{ ok: true, chainId, assetAddress }` when the user accepts. + +Users can also add assets from the Balances tab without a host RPC. The Balances list shows tracked assets for the currently selected network only (USDC is always tracked per supported chain). + +## Other Host APIs + +| API | Use | +|-----|-----| +| `proxy.ethereum.request(...)` | EIP-1193 (accounts, sign, chain, …) | +| `proxy.credentials.*` | OID4 offer / present (when enabled in wallet) | +| `proxy.showWallet()` / `hideWallet()` | Host-driven flyout without an EIP-1193 call | +| `proxy.rpc(method, params)` | Custom Branding RPC (`setStyle`, `focusWallet`, `unfocusWallet`, `addAsset`, …) | + +## Hard rules + +- Never embed the Signing Layer iframe from the Host — always Host → Branding → Signing. +- Prefer the published wallet URL in production; point at a local Branding origin only while developing this repo. +- Theme with `setStyle`; do not ask integrators to fork CSS for basic brand colors / product name. +- Use `focusWallet` / `unfocusWallet` for host-driven single-asset flows; do not expose mode switching in the wallet UI. +- Use `addAsset` when the host wants a lasting Balances entry; expect a confirm modal (contrast with `focusWallet`). diff --git a/.agents/skills/ows-branding-layer/SKILL.md b/.agents/skills/ows-branding-layer/SKILL.md new file mode 100644 index 0000000..c34a9f8 --- /dev/null +++ b/.agents/skills/ows-branding-layer/SKILL.md @@ -0,0 +1,179 @@ +--- +name: ows-branding-layer +description: >- + Build an Open Wallet Standard (OWS) Branding Layer with @1shotapi/ows-wallet-utils, + ows-signer-utils, ows-types, and optional ows-oid4. Use when scaffolding a branding + wallet, wiring OWSWallet/OWSSigner, RpcHelper/SignHelper/CredentialsHelper, passkey + unlock, custom host RPC, signing consent, recovery overlay, credentials, or display + shell in a separate repo. +license: MIT +metadata: + author: 1Shot-API + version: "0.3.0" + repository: https://github.com/1Shot-API/open-wallet +--- + +# OWS Branding Layer + +Teach an agent how to scaffold and extend an **OWS Branding Layer** in any repository. + +UI, flow ownership, and display queuing stay **app-owned**. Published packages provide thin protocol helpers (`RpcHelper`, `SignHelper`, `CredentialsHelper`, `overlaySignerIframe`) — not a module/registry runtime. + +When a local clone of [open-wallet](https://github.com/1Shot-API/open-wallet) is available (multi-root workspace), prefer grepping `examples/general-wallet` over guessing from this skill alone. + +## Install this skill (consumer repo) + +```bash +npx skills add 1Shot-API/open-wallet@ows-branding-layer +# or: npx skills add https://github.com/1Shot-API/open-wallet --skill ows-branding-layer +# or from a sibling clone: npx skills add ../open-wallet --skill ows-branding-layer +``` + +Then invoke `/ows-branding-layer` or ask to build a branding wallet. + +## Architecture (non-negotiable) + +``` +Host Layer @1shotapi/ows-provider EIP-1193 / credentials proxy + └── Branding THIS APP UX + Postmate child + helpers + └── Signing @1shotapi/ows-signer WebAuthn PRF custody (nested iframe) +``` + +- Host **never** embeds the Signing Layer. Always Host → Branding → Signing. +- Signing Layer accepts `postMessage` only from `window.parent` when `window.parent !== window.top`. +- Passkeys require a **secure context on the entire ancestor chain** — Host and Branding/Signer must be HTTPS (or `localhost` / `*.localhost`). +- `allowLocalAccess` is a **host** `OWSProxy` option (LAN OID4 from a public branding origin). Do not put it on branding APIs. + +Details: [references/architecture.md](references/architecture.md) + +## Packages + +| Package | Role | +|---------|------| +| `@1shotapi/ows-types` | Branded primitives, errors, credential types, EIP-1193 tables, `CredentialCryptoUtils` / `PresentationUtils` / `ProofUtils` | +| `@1shotapi/ows-wallet-utils` | Branding ↔ Host (`OWSWallet`, `RpcHelper`, `requestDisplay`) | +| `@1shotapi/ows-signer-utils` | Branding ↔ Signing (`OWSSigner`, `SignHelper`, `overlaySignerIframe`) | +| `@1shotapi/ows-oid4` | Optional credentials (`CredentialsHelper`, HTTP OID4VCI/OID4VP clients) | +| `@1shotapi/ows-signer` | Plain JS Signing Layer sources (serve as static `/signer/`) | + +```bash +npm install \ + @1shotapi/ows-types \ + @1shotapi/ows-wallet-utils \ + @1shotapi/ows-signer-utils \ + @1shotapi/ows-oid4 \ + viem +# zod is transitive via ows-wallet-utils — add a direct dep only for custom registerRpc schemas +# Serve/copy ows-signer HTML+JS from the same origin as branding (rpId = signer hostname) +``` + +Details: [references/packages.md](references/packages.md) + +## Task checklist + +Copy and track progress. Order matches a typical first branding app; specialized wallets may skip EIP-1193 and lead with custom host RPC. The reference wallet (`examples/general-wallet`) is **EIP-1193-first**. + +``` +Branding Layer Progress: +- [ ] 1. Scaffold — packages + serve Signing Layer on same origin at /signer/ +- [ ] 2. Unlock — passkey create/login (`awaitSignerReady` vs `ensureReady`) +- [ ] 3. Display shell — requestDisplay / hide + app-owned modal queue +- [ ] 4. Host RPC — EIP-1193 (RpcHelper + account connect) and/or custom registerRpc +- [ ] 5. Signing consent — SignHelper + app approval UI +- [ ] 6. Recovery overlay — create/restore with overlaySignerIframe (no reparent) +- [ ] 7. Credentials — CredentialsHelper.register + consent UI (optional) +``` + +Task details + exact example paths: [references/tasks.md](references/tasks.md) + +### Minimal boot sequence + +Stock Postmate parents only retry the handshake briefly after branding iframe `load`. **Register `Postmate.Model` via `wallet.start()` before the nested Signing Layer finishes loading.** Use a deferred signer proxy so helpers can register with an `OWSSigner`-shaped object immediately. + +Canonical implementation: `examples/general-wallet/src/wallet/WalletProvider.tsx` (`createDeferredSigner` + `boot`). + +```typescript +import { OWSSigner, SignHelper } from "@1shotapi/ows-signer-utils"; +import { OWSWallet, RpcHelper } from "@1shotapi/ows-wallet-utils"; +// Optional credentials: +// import { CredentialsHelper } from "@1shotapi/ows-oid4"; + +const wallet = OWSWallet.prepare({ debug: false }); + +const signerPromise = OWSSigner.create(signerContainer, signerUrl, { + hidden: true, + credentialId: loadCredentialId(), +}); +// Proxy that throws until signerPromise resolves — do NOT await signer before start(). +const signer = createDeferredSigner(() => signerPromise); // copy pattern from WalletProvider + +// Register before start (order in general-wallet): +// account connect → RpcHelper → SignHelper handlers → CredentialsHelper.register() +const rpcHelper = new RpcHelper(providers, wallet, signer, { defaultChainId }); + +const signHelper = new SignHelper(signer, wallet, { + // Setup-only when no credential; signing ceremony unlocks when credential exists + ensureReady: ensureOnboardedForSigning, + onAuthenticated: markUnlockedAndRefreshAddresses, + getChainId: () => rpcHelper.getChainId(), + requestPersonalSignApproval, + requestSignTypedDataApproval, + approveAndSignTransaction, // branding: consent + prepare + sign + broadcast +}); +for (const [method, handler] of Object.entries(signHelper.handlers)) { + wallet.registerEip1193(method, handler); +} + +// Optional: +// new CredentialsHelper(wallet, signer, { … }).register(); +// Note arg order: CredentialsHelper(wallet, signer) vs SignHelper(signer, wallet) + +void wallet.start(); // registers Model immediately — do not await nested signer first +void signerPromise; // background load; UI can paint +``` + +Prefer **`OWSWallet.prepare()` → register handlers → `start()`**. Do not use a module install runtime. + +Split readiness in your app: + +| API | Meaning | +|-----|---------| +| `awaitSignerReady()` | Nested Signing Layer iframe + `OWSSigner` loaded | +| `ensureReady()` | Signer loaded **and** unlocked / onboarded (passkey) — for connect, credentials, recovery create | +| `ensureOnboardedForSigning()` | Setup/login **only if no credential id**; otherwise no-op — for `SignHelper` signed actions | +| `onAuthenticated` | After successful sign / send: set unlocked + refresh addresses | + +For custom RPCs such as `eth_sendTransaction` or future ERC-7710 sends, use the same **centralized signed-action gate**: setup-only before consent when the credential is missing; one passkey ceremony for the sign itself when the credential is known. + +Restore-backup must use **`awaitSignerReady` only** — calling `ensureReady` first can force setup/login before recovery. + +## Hard rules (from OWS) + +1. **Branded types** from `@1shotapi/ows-types` — use constructors (`EVMAccountAddress(...)`), never `as` casts. +2. **No deprecations** during pre-1.0 — rename and update all call sites. +3. Prefer **methods on objects** over free helpers when logic belongs to one class. +4. Set iframe `allow` for WebAuthn/clipboard **before** navigation (`OWSSigner` / host `OWSProxy` already do this). +5. Do not vendor Postmate — use the `postmate` package (transitive). +6. Do **not** reparent the signer iframe for passphrase UI — use `overlaySignerIframe`. +7. Prefer Vite `/signer/` at **origin root** even if the branding app uses a subpath `base` (e.g. `/wallet/`). + +## Reference implementation + +Canonical React + Vite + Tailwind demo: `examples/general-wallet` in [1Shot-API/open-wallet](https://github.com/1Shot-API/open-wallet). + +| Concern | Start here | +|---------|------------| +| Boot / deferred signer | `src/wallet/WalletProvider.tsx` | +| Account connect | `src/ows/registerAccountConnect.ts` | +| Sign consent | `src/ows/registerApprovalSigning.ts`, `src/components/modals/SignModals.tsx` | +| Credentials | `src/ows/registerCredentialsProvider.ts`, `src/components/modals/CredentialModals.tsx` | +| Recovery | `src/components/modals/BackupModals.tsx` | +| Serve signer | `vite.config.ts`, `scripts/copy-signer.mjs`, `signer-static/index.html` | + +## Out of scope for this skill + +- Host Layer apps (`ows-provider`) — separate concern (`allowLocalAccess`, flyout chrome) +- Implementing or modifying `ows-signer` internals +- Publishing npm packages / changesets +- Shared React/Tailwind UI kits (apps own their UI) +- Calling `PresentationUtils` / SD-JWT low-level APIs unless you bypass `CredentialsHelper` diff --git a/.agents/skills/ows-branding-layer/references/architecture.md b/.agents/skills/ows-branding-layer/references/architecture.md new file mode 100644 index 0000000..6498f17 --- /dev/null +++ b/.agents/skills/ows-branding-layer/references/architecture.md @@ -0,0 +1,53 @@ +# OWS Branding Layer architecture notes + +## Three layers + +| Layer | Runs where | Trust / UX | +|-------|------------|------------| +| Host | Integrator dapp | EIP-1193 / credentials proxy; must be secure context if it embeds HTTPS wallet | +| Branding | Wallet origin (your product) | Branding, consent, app-owned UI + SDK helpers | +| Signing | Nested iframe (prefer same origin as branding) | Passkeys / PRF; minimal attack surface | + +## Origins and `rpId` + +- Signing Layer `rpId` is always `window.location.hostname` of the signer document. +- Prefer **same origin** for branding + signer (shared passkey namespace). +- Reference layout: branding UI may live under a path (`/wallet/`) while signer document is at `/signer/` on the **same origin**. +- Canonical CDN / on-chain gateway is an alternative shared `rpId` for all integrators. + +## Secure contexts + +Browsers require the **entire iframe ancestor chain** to be secure for WebAuthn. Therefore: + +- Production hosts: HTTPS +- Local custom hosts (`ows-host.com`): HTTPS via mkcert (see `examples/host` in open-wallet) +- `http://localhost` remains a special-case secure context +- Branding demos often use ngrok HTTPS (`examples/general-wallet/scripts/dev.mjs`) + +## Display protocol + +Branding: + +1. `const display = await wallet.requestDisplay({ width, height })` +2. Run WebAuthn / approval UI inside the branding iframe +3. `await display.hide()` (or `wallet.requestHide()`) + +Host `OWSProxy` shows a lower-right opaque flyout (no modal backdrop). Protocol events include `ows:requestDisplay`, `ows:requestHide`, and `ows:releaseDisplay` (session `release()` / hide). Apps should not invent parallel display protocols. + +Display **queuing** (serializing concurrent modals) is app-owned — see `WalletProvider` modal queue in general-wallet. + +## Host handshake vs nested signer + +Stock Postmate child handshake windows are short. Host-side `OWSProxy` extends the handshake timeout for slow tunnels, but branding must still **`wallet.start()` before awaiting the nested Signing Layer** so the parent finds a Model. Deferred signer proxies keep handlers registerable immediately. + +## Host-only: Local Network Access + +When a public branding origin (e.g. ngrok) fetches private/loopback OID4 endpoints, Chrome may require the host iframe `allow` attribute. That is configured on **`OWSProxy.create(..., { allowLocalAccess: true })`**, not on branding wallet APIs. + +## Integrity (future) + +Signed-manifest / verify-before-execute for `ows-signer` is planned (see `packages/ows-signer/docs/trusted-loader-plan.md`). Not required for a first branding scaffold. + +## Reference demo + +`examples/general-wallet` + `examples/host` (and credential issuer/verifier hosts) in the open-wallet monorepo demonstrate Host → Branding → Signing with HTTPS passkeys. diff --git a/.agents/skills/ows-branding-layer/references/packages.md b/.agents/skills/ows-branding-layer/references/packages.md new file mode 100644 index 0000000..7f74748 --- /dev/null +++ b/.agents/skills/ows-branding-layer/references/packages.md @@ -0,0 +1,70 @@ +# OWS packages for a Branding Layer + +## Install + +Aligned with `examples/general-wallet/package.json`: + +```bash +npm install \ + @1shotapi/ows-types \ + @1shotapi/ows-wallet-utils \ + @1shotapi/ows-signer-utils \ + @1shotapi/ows-oid4 \ + viem +``` + +Notes: + +- `viem` is a peer of `ows-signer-utils` (EVM helpers). +- `postmate` and `zod` are **transitive** via `ows-wallet-utils` — do not vendor Postmate; add a direct `zod` dep only if you author custom `registerRpc` schemas. +- `@1shotapi/ows-signer` is plain JS sources. Prefer **copy or static-serve** into your app (no build step). Same origin as branding so `rpId === location.hostname`. Consuming it solely as an npm import of TS modules is not the reference pattern. +- `@1shotapi/ows-provider` is for **host** apps only — omit from a branding-only package unless the same origin also serves a demo host. + +Until packages are on npm, depend via workspace, `file:`, or git: + +```bash +# example — sibling clone +npm install ../open-wallet/packages/ows-types +``` + +## Package roles + +| Package | Import surface | Branding uses it for | +|---------|----------------|----------------------| +| `ows-types` | primitives, errors, credentials, EIP-1193 tables, `CredentialCryptoUtils`, `PresentationUtils`, `ProofUtils` | Branded values, holder signer bridge, shared errors | +| `ows-wallet-utils` | `OWSWallet`, `RpcHelper`, display child client | Postmate child, EIP-1193 / custom RPC registration, reads/chain, `requestDisplay` | +| `ows-signer-utils` | `OWSSigner`, `SignHelper`, `overlaySignerIframe`, `evm.*` | Nested signer iframe, consent→sign wiring, digests → signatures | +| `ows-oid4` | `CredentialsHelper`, `HttpOid4vciClient`, `HttpOid4vpClient`, … | Optional OID4 accept/present orchestration | +| `ows-signer` | static files | Custody kernel under `/signer/` | + +There is **no** branding-core / registry package. App-local UI and wiring live in your repo (see `examples/general-wallet/src/ows/`). + +### Constructor arg order (easy to swap) + +| Helper | Order | +|--------|-------| +| `SignHelper` | `(signer, wallet, options)` | +| `CredentialsHelper` | `(wallet, signer, options)` | + +## Serving the Signing Layer + +Mirror `examples/general-wallet`: + +- Branding app may use Vite `base: "/wallet/"` for the UI. +- Signer is served at **`/signer/` outside that base** via middleware so WebAuthn + nest stay same-origin (`vite.config.ts` `serveSignerPlugin`). +- HTML shell: `signer-static/index.html`; JS from package `src/`. +- Prod: `scripts/copy-signer.mjs` → `dist/signer/`. + +```typescript +const signerUrl = new URL("/signer/", window.location.origin).href; +``` + +## Credentials stack (optional) + +Prefer `CredentialsHelper` + HTTP clients from **`@1shotapi/ows-oid4`**. Demo-only stores / trust / keys live under `examples/shared` in open-wallet (not published) — product apps supply their own `ICredentialRepository`, trust registry, and OID4 clients. + +Holder signing: omit `holderSigner` to use the default OWS Ed25519 bridge (`CredentialCryptoUtils.createOwsEd25519HolderSigner` in `ows-types`), or pass your own `IHolderSigner`. + +## Host apps (not branding) + +Hosts use `@1shotapi/ows-provider` (`OWSProxy`) only. Host-only options include `allowLocalAccess` (iframe permissions for Local Network Access when branding on a public origin fetches `127.0.0.1` / private LAN OID4 endpoints). diff --git a/.agents/skills/ows-branding-layer/references/tasks.md b/.agents/skills/ows-branding-layer/references/tasks.md new file mode 100644 index 0000000..dd8c8c6 --- /dev/null +++ b/.agents/skills/ows-branding-layer/references/tasks.md @@ -0,0 +1,160 @@ +# Branding Layer tasks + +Task-oriented guidance aligned with `examples/general-wallet`. Specialize freely; production branding apps may lead with **custom host RPC** and skip MetaMask-shaped EIP-1193. The reference wallet is **EIP-1193-first**. + +Paths below are under `examples/general-wallet/` unless noted. + +## 1. Scaffold + +1. Install packages (see [packages.md](packages.md)). +2. Serve `@1shotapi/ows-signer` as static **`/signer/` on the same origin** as branding (`rpId === location.hostname`). + - Dev: Vite middleware maps `/signer/` → `signer-static/index.html` and `/signer/src/` → package `src/` (**outside** Vite `base`, e.g. `/wallet/`). See `vite.config.ts`. + - Prod: `scripts/copy-signer.mjs` into `dist/signer/`. +3. Signer URL: `new URL("/signer/", window.location.origin).href` (origin root, not under app `base`). +4. Create a hidden signer host (`src/components/SignerHost.tsx`), then **start Postmate before awaiting nested signer load**: + +```typescript +const wallet = OWSWallet.prepare(); +const signerPromise = OWSSigner.create(container, signerUrl, { + hidden: true, + credentialId: loadCredentialId(), +}); +const signer = createDeferredSigner(() => signerPromise); // WalletProvider.tsx +// …register handlers (ensureReady awaits signerPromise + unlock)… +void wallet.start(); // registers Model immediately +void signerPromise; // background — do not block UI/paint on this +``` + +Also see `scripts/dev.mjs` (ngrok HTTPS for passkeys). Do not run leftover `--no-tunnel` processes on the same port while hosts expect a tunnel URL. + +## 2. Unlock (`awaitSignerReady` vs `ensureReady`) + +App-owned passkey create / login. Split readiness: + +| Helper | Waits for | +|--------|-----------| +| `awaitSignerReady()` | Signing Layer iframe + `OWSSigner` instance | +| `ensureReady()` | Signer ready **plus** unlocked / wallet created | + +- Persist credential id + cached addresses in app storage (`src/storage.ts`). +- Gate signing and account-connect behind `ensureReady()`. +- Embedded first-run UI when `window.parent !== window.top` is optional — `OnboardingPanel.tsx` / `WalletProvider.tsx` `runSetupFlow`. +- Host-driven setup: wrap with `requestDisplay` + setup modals (`SetupModals.tsx`). + +No published SDK for setup dialogs; keep UI local. + +## 3. Display shell + +Before WebAuthn or consent UI in a cross-origin host embed: + +```typescript +const display = await wallet.requestDisplay({ width, height }); +try { + // dialogs / passkey / overlay +} finally { + await display.hide(); +} +``` + +Also available: `wallet.requestHide()`. Host wire event `ows:releaseDisplay` is reached via `DisplaySession.release()` / hide paths — branding apps should prefer `display.hide()`. + +Host `OWSProxy` shows a lower-right opaque flyout (no modal backdrop). + +**App concerns (not SDK):** + +- **Modal queue** — serialize concurrent dialogs (`WalletProvider` `pushModal` / `ModalHost.tsx`). +- Nested `requestDisplay` may reuse an active session (depth). Helpers (`SignHelper`, `CredentialsHelper`) already call `requestDisplay` — **do not double-wrap** those handler paths with another outer display. + +## 4. Host RPC (primary for specialized wallets) + EIP-1193 + +**Specialized wallets:** register custom methods the host calls via `proxy.rpc` / your protocol (`wallet.registerRpc`). + +**Reference wallet path (EIP-1193):** + +1. `src/ows/registerAccountConnect.ts` — `eth_accounts` / `eth_requestAccounts` (cached addresses; connect consent + `ensureReady`). +2. `RpcHelper` for JSON-RPC reads / `wallet_switchEthereumChain` (`src/ows/demoChains.ts`, construct in `WalletProvider.tsx`). +3. `SignHelper` for `personal_sign` / typed data (task 5). + +```typescript +new RpcHelper( + new Map([[chainId, rpcUrl] /* … */]), + wallet, + signer, // optional; unused for reads today + { defaultChainId }, +); +``` + +Call after `prepare()`, before `start()`. Zod EIP-1193 / credential wire schemas live in `ows-wallet-utils` (transitive). + +## 5. Signing consent + +Headless wiring in `SignHelper` (`@1shotapi/ows-signer-utils`): + +``` +requestDisplay → consent UI → ensureReady → signer.evm.signMessage | signTypedData → hide +``` + +Registers: `personal_sign`, `eth_signTypedData`, `eth_signTypedData_v3`, `eth_signTypedData_v4`, `eth_sendTransaction`. Reject with `OwsUserRejectedError`. + +Create `RpcHelper` before `SignHelper`. Pass `getChainId` from RpcHelper. Branding implements `approveAndSignTransaction` (consent + prepare + sign + broadcast; use exported `prepareEvmTransaction`). Use a **setup-only** `ensureReady` plus `onAuthenticated` after message/typed-data ceremonies (send auth side effects live in branding's approve callback). + +```typescript +const signHelper = new SignHelper(signer, wallet, { + ensureReady: ensureOnboardedForSigning, + onAuthenticated, + getChainId: () => rpcHelper.getChainId(), + requestPersonalSignApproval, // PersonalSignApprovalRequest → boolean + requestSignTypedDataApproval, // SignTypedDataApprovalRequest → boolean + approveAndSignTransaction, // SendTransactionApprovalRequest → EVMTransactionHash +}); +for (const [method, handler] of Object.entries(signHelper.handlers)) { + wallet.registerEip1193(method, handler); +} +``` + +Reference: `src/ows/registerApprovalSigning.ts`, `src/components/modals/SignModals.tsx`, `src/wallet/withWalletReady.ts` (embedded-wallet). + +## 6. Recovery overlay + +Create / restore encrypted backup — **never reparent** the signer iframe. + +Reference: `src/components/modals/BackupModals.tsx`, called from `WalletProvider` `openCreateBackup` / `openRestoreBackup`. + +1. Outer `wallet.requestDisplay` for the dialog shell (WalletProvider wrappers). +2. `overlaySignerIframe(iframe, slot, { homeContainer })` so the passphrase UI appears over a slot. +3. **Double `requestAnimationFrame`** after overlay before calling signer RPCs (layout must settle). +4. Create: `ensureReady()` then `signer.createRecoveryData(passwordText, buttonText, minPasswordLength)`. +5. Restore: **`awaitSignerReady()` only** (not `ensureReady`) then `signer.recoverKey(encrypted, passwordText, buttonText)`. +6. Always restore overlay styles in `finally` / abort cleanup; then `display.hide()`. + +Distinct from `prepareSignerIframeForWebAuthn` (1×1 invisible passkey focus used **inside** `OWSSigner`). + +## 7. Credentials (optional) + +Prefer `CredentialsHelper` from `@1shotapi/ows-oid4` (not exported from `ows-wallet-utils`): + +```typescript +new CredentialsHelper(wallet, signer, { + repository, + oid4vci, + oid4vp, + trust, + ensureReady, + requestCredentialOfferApproval, + requestCredentialPresentationApproval, + // holderSigner optional — defaults via CredentialCryptoUtils.createOwsEd25519HolderSigner +}).register(); +``` + +Constructor arg order: **`(wallet, signer, options)`** — opposite of `SignHelper(signer, wallet, …)`. + +Reference stack: + +- Wiring: `src/ows/registerCredentialsProvider.ts` +- Consent UI: `src/components/modals/CredentialModals.tsx` +- Demo repositories / trust / HTTP clients fixtures: `examples/shared` (alias `@ows-shared` in the monorepo — not published) +- Wire methods: `credentials.acceptOffer` / `present` / `list` / `delete` + +Do **not** call `PresentationUtils` / low-level SD-JWT helpers unless you bypass `CredentialsHelper`. Holder KB JWT lives in `CredentialCryptoUtils.createOwsEd25519HolderSigner` (`@1shotapi/ows-types`). + +Host demos: `examples/credential-issuer`, `examples/credential-verifier` (use `ows-provider`; `allowLocalAccess` is host-only). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5631ebb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +.git +.agents +*.md +.DS_Store +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2aca9f --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Ngrok (required for HTTPS passkey testing with a host dapp) +# https://dashboard.ngrok.com/get-started/your-authtoken +NGROK_AUTHTOKEN= + +# Optional reserved domain (hostname only or full URL) +# https://dashboard.ngrok.com/domains +# NGROK_DOMAIN=your-name.ngrok-free.app + +# Optional Vite port for the Branding Layer (default 5174) +# PORT=5174 +# WALLET_PORT=5174 + +# Test Host Layer (`npm run dev:host`, default port 5173) +# HOST_PORT=5173 +# Explicit Branding iframe URL (overrides NGROK_DOMAIN / localhost) +# WALLET_IFRAME_URL=https://your-name.ngrok-free.app/ + +# Optional HTTPS for the test host (passkeys when wallet is on HTTPS/ngrok) +# HOST_HTTPS=1 +# HOST_SSL_CERT=host/certs/dev-cert.pem +# HOST_SSL_KEY=host/certs/dev-key.pem diff --git a/.github/workflows/Deploy Dev.yaml b/.github/workflows/Deploy Dev.yaml new file mode 100644 index 0000000..51525b1 --- /dev/null +++ b/.github/workflows/Deploy Dev.yaml @@ -0,0 +1,135 @@ +name: 1Shot Wallet - Build, Push, and Deploy to Dev + +on: + push: + branches: + - develop + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PRODUCT_NAME: oneshot + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF: ${{ github.ref }} + REGISTRY_HOSTNAME: gcr.io + GAR_LOCATION: us-central1 + GKE_ZONE: us-central1-a + PROJECT_ID: shot-dev + GCP_CREDENTIALS: ${{ secrets.DEV_GCP_CREDENTIALS }} + GOOGLE_APPLICATION_CREDENTIALS: ~/.gcp/credentials.json + GOOGLE_PROJECT: shot-dev + GOOGLE_REGION: us-central1 + GOOGLE_ZONE: us-central1-a + BUILD_ENV: develop + SSH_KEY: ${{ secrets.DEV_SSH_KEY }} + # GCE instance name in GOOGLE_ZONE (single dev VM) + DEV_VM_INSTANCE: dev-vm + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + wallet-dockerize: + name: Dockerize Code + runs-on: + labels: [ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v5 + - uses: dorny/paths-filter@v4 + id: changes + with: + filters: | + packages: + - '**' + # Setup gcloud CLI + - id: "auth" + uses: "google-github-actions/auth@v3" + with: + credentials_json: "${{ secrets.DEV_GCP_CREDENTIALS }}" + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v3" + - name: Docker configuration + #if: steps.changes.outputs.packages == 'true' + run: |- + gcloud --quiet auth configure-docker $GAR_LOCATION-docker.pkg.dev + + - name: Build Wallet + run: |- + docker build \ + --tag "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet:$GITHUB_SHA" \ + --tag "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet:${GITHUB_REF##*/}" \ + --tag "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet:latest" \ + --build-arg PRODUCT_NAME="$PRODUCT_NAME" \ + --build-arg GITHUB_SHA="$GITHUB_SHA" \ + --build-arg GITHUB_REF="$GITHUB_REF" \ + --build-arg BASE_TAG="$GITHUB_SHA" \ + -f ./Dockerfile \ + . + + # Push the Docker image to Google Artifact Registry + - name: Publish Wallet + run: |- + docker push "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet" --all-tags + + google-deployment: + name: Deploy to Google + runs-on: + labels: [ubuntu-latest] + if: ${{ success() && contains(join(needs.*.result, ','), 'success') }} + needs: [wallet-dockerize] + steps: + - name: Checkout + uses: actions/checkout@v5 + + - id: "auth" + uses: "google-github-actions/auth@v3" + with: + credentials_json: "${{ secrets.DEV_GCP_CREDENTIALS }}" + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v3" + # SSH into the VM and run the deploy script + - name: Set up SSH key + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Pull and Run Latest + run: | + set -euo pipefail + ZONE="${GOOGLE_ZONE}" + VM="${DEV_VM_INSTANCE}" + EXTERNAL_IP=$(gcloud compute instances describe "$VM" --zone="$ZONE" --project="$PROJECT_ID" \ + --format='get(networkInterfaces[0].accessConfigs[0].natIP)') + EXTERNAL_IP="${EXTERNAL_IP//$'\r'/}" + EXTERNAL_IP="${EXTERNAL_IP//[[:space:]]/}" + if [[ -z "${EXTERNAL_IP}" ]]; then + echo "::error::VM '$VM' in zone '$ZONE' has no external NAT IP (networkInterfaces[0].accessConfigs[0].natIP is empty). Attach a public IP or change the deploy method (e.g. IAP)." + exit 1 + fi + echo "==================== Deploying wallet to $VM ($EXTERNAL_IP) ====================" + ssh-keyscan -t ed25519 -H "$EXTERNAL_IP" >> ~/.ssh/known_hosts 2>/dev/null || true + ssh-keyscan -H "$EXTERNAL_IP" >> ~/.ssh/known_hosts 2>/dev/null || true + ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 "github@${EXTERNAL_IP}" bash -s << 'EOF' + cd /home/github + echo "----------------------------------------------------------------------------------------" + echo "----------------------- Start of Wallet Deployment Script ------------------------------" + echo "----------------------------------------------------------------------------------------" + + echo "---------------------------- Docker Deployment ---------------------------------------" + # Pull the latest images + docker-compose pull + + # Stop necessary services + docker stop wallet || true + docker rm -f wallet || true + + # Reup everything + docker-compose up -d + + # Prune dead resources + docker system prune -f + + # If the downtime is too much in the future, https://github.com/wowu/docker-rollout?tab=readme-ov-file#%EF%B8%8F-caveats + EOF diff --git a/.github/workflows/Deploy Prod.yaml b/.github/workflows/Deploy Prod.yaml new file mode 100644 index 0000000..0ba27c3 --- /dev/null +++ b/.github/workflows/Deploy Prod.yaml @@ -0,0 +1,113 @@ +name: 1Shot Wallet - Build, Push, and Deploy to Production + +on: + push: + branches: + - main + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PRODUCT_NAME: oneshot + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF: ${{ github.ref }} + REGISTRY_HOSTNAME: gcr.io + GAR_LOCATION: us-central1 + GKE_ZONE: us-central1-a + PROJECT_ID: shot-prod-493103 + GCP_CREDENTIALS: ${{ secrets.PROD_GCP_CREDENTIALS }} + GOOGLE_APPLICATION_CREDENTIALS: ~/.gcp/credentials.json + GOOGLE_PROJECT: shot-prod-493103 + GOOGLE_REGION: us-central1 + GOOGLE_ZONE: us-central1-a + BUILD_ENV: develop + SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + wallet-dockerize: + name: Dockerize Code + runs-on: + labels: [ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v5 + - uses: dorny/paths-filter@v4 + id: changes + with: + filters: | + packages: + - '**' + # Setup gcloud CLI + - id: "auth" + uses: "google-github-actions/auth@v3" + with: + credentials_json: "${{ secrets.PROD_GCP_CREDENTIALS }}" + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v3" + - name: Docker configuration + #if: steps.changes.outputs.packages == 'true' + run: |- + gcloud --quiet auth configure-docker $GAR_LOCATION-docker.pkg.dev + + - name: Build Wallet + run: |- + docker build \ + --tag "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet:$GITHUB_SHA" \ + --tag "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet:${GITHUB_REF##*/}" \ + --tag "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet:latest" \ + --build-arg PRODUCT_NAME="$PRODUCT_NAME" \ + --build-arg GITHUB_SHA="$GITHUB_SHA" \ + --build-arg GITHUB_REF="$GITHUB_REF" \ + --build-arg BASE_TAG="$GITHUB_SHA" \ + -f ./Dockerfile \ + . + + # Push the Docker image to Google Artifact Registry + - name: Publish Wallet + run: |- + docker push "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$PRODUCT_NAME/oneshot-wallet" --all-tags + + google-deployment: + name: Deploy to Google + runs-on: + labels: [ubuntu-latest] + if: ${{ success() && contains(join(needs.*.result, ','), 'success') }} + needs: [wallet-dockerize] + steps: + - name: Checkout + uses: actions/checkout@v5 + + - id: "auth" + uses: "google-github-actions/auth@v3" + with: + credentials_json: "${{ secrets.PROD_GCP_CREDENTIALS }}" + - name: "Set up Cloud SDK" + uses: "google-github-actions/setup-gcloud@v3" + # SSH into the VM and run the deploy script + - name: Set up SSH key + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Pull and Run Latest + run: | + set -euo pipefail + ZONE="${GOOGLE_ZONE}" + for VM in prod-vm prod-vm-2; do + EXTERNAL_IP=$(gcloud compute instances describe "$VM" --zone="$ZONE" --project="$PROJECT_ID" \ + --format='get(networkInterfaces[0].accessConfigs[0].natIP)') + EXTERNAL_IP="${EXTERNAL_IP//$'\r'/}" + EXTERNAL_IP="${EXTERNAL_IP//[[:space:]]/}" + if [[ -z "${EXTERNAL_IP}" ]]; then + echo "::error::VM '$VM' in zone '$ZONE' has no external NAT IP (networkInterfaces[0].accessConfigs[0].natIP is empty). Attach a public IP or change the deploy method (e.g. IAP)." + exit 1 + fi + echo "==================== Deploying wallet to $VM ($EXTERNAL_IP) ====================" + ssh-keyscan -t ed25519 -H "$EXTERNAL_IP" >> ~/.ssh/known_hosts 2>/dev/null || true + ssh-keyscan -H "$EXTERNAL_IP" >> ~/.ssh/known_hosts 2>/dev/null || true + ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 "github@${EXTERNAL_IP}" 'bash -s' < infra/scripts/deploy-prod-wallet-vm-remote.sh + done diff --git a/.gitignore b/.gitignore index 872d5f6..a8299ab 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,12 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Local HTTPS certs (mkcert) for host tester +host/certs/*.pem + +# Deploy SSH keypairs (never commit) +oneshot-wallet-*-deploy +oneshot-wallet-*-deploy.pub +*-deploy +*-deploy.pub diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..885bf23 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Agent instructions — 1Shot Embedded Wallet + +Branding Layer for the 1Shot Wallet (`wallet.1shotapi.com`). Host → Branding → Signing; never embed `/signer/` from the Host. + +## Compatibility policy (until further notice) + +Prefer **clean code over backwards compatibility**. Do not add legacy redirects, `@deprecated` aliases, dual APIs, compatibility shims, or parallel code paths “just in case.” When a pattern is renamed or superseded, update all call sites in this repo (and docs/skills) to the new shape — breaking changes are expected and welcome during active development. + +## Layout + +| Path | Content | +|------|---------| +| `/` | Branding Layer (React SPA) | +| `/signer/` | Signing Layer (`@1shotapi/ows-signer`) | +| `src/lib/types/primitives/` | Wallet-local branded types (one file each) | +| `src/lib/types/enum/` | Domain enums (`EAssetType`, `EWalletEventKind`, …) | +| `src/lib/types/business/` | Domain DTOs (e.g. `KnownAsset`, `TrackedAsset`) | +| `src/lib/types/events/` | Domain event classes (one file each) | +| `src/lib/interfaces/{business,data,utils}/` | Layer interfaces | +| `src/lib/implementations/{business,data,utils}/` | Layer implementations | +| `src/assets/` | Static media only (SVGs, images) | + +Test Host Layer: `host/` (`npm run dev:host`). Style via Host RPC `setStyle`, not in-wallet debug knobs. + +### Form validation UX + +Primary submit actions (e.g. Send in `TransferTokensModal`) stay **disabled until every required field is valid**. Do not leave the button enabled and only reject on click. Empty fields show no error text; invalid non-empty input shows inline errors; the CTA enables only when the whole form is ready. + +### Passkey ceremony overlays + +Every WebAuthn prompt (Signing Layer PRF via `OWSSigner`, or Relayer `createRelayerAssertion`) must show the non-interactive `PasskeyPromptModal` for the duration of the ceremony—no Continue/Done. New ceremony entry points go through `withPasskeyPrompt` or the wrapped signer from `wrapSignerWithPasskeyPrompts` (applied once after `OWSSigner.create` in `WalletProvider`). Copy lives under `style.copy.passkeyPrompt`. + +### User-facing copy (`setStyle`) + +When adding or changing UI strings: + +1. Wire them through `style.copy` (defaults, types, and `registerSetStyle` Zod schema) so hosts can override via `setStyle`. +2. Expose the same keys in **both** WalletConfigurator playgrounds: `host/` in this repo and `app/playground/` in **1Shot-API-Website-New** (`styleForm.ts` + `WalletConfigurator.tsx`). + +### Domain layers (assets example) + +- **Utils:** `IBlockchainProvider` / `AddressUtils` (from `@1shotapi/ows-wallet-utils`) / `DemoChainsBlockchainProvider`, `IEventBus` / `EventBus`, `ITransactionUtils` / `TransactionUtils` +- **Data:** `IKnownAssetRepository`, `ITrackedAssetRepository`, `IOneshotRelayerRepository` (`src/lib`) and their implementations +- **Business:** services that orchestrate domain logic (add as needed) + +`IOneshotRelayerRepository.sendTransaction` owns prepare + passkey sign + broadcast (interim: `eth_sendRawTransaction`). Host EIP-1193 sends go SignHelper → branding `approveAndSignTransaction` (ConfirmTransfer / SendTransaction consent) → relayer. In-wallet Send uses `TransferTokensModal` → `WalletProvider.sendTransaction` → relayer, then `SentTransactionModal` (hash + explorer link). Host-driven sends do not show that confirmation — the host surfaces the hash itself. + +## Branded types + +Prefer branded primitives from `@1shotapi/ows-types` when a shared type already exists. For **wallet-local** branded types (e.g. `TrackedAssetId`), put each brand in its **own file** under `src/lib/types/primitives/` (type alias + `make()` constructor, same pattern as `ows-types` primitives) — do not declare brands inline in DTO modules. + +**Trust brands after construction.** Validate shape (`string` / `number` / `boolean`, regex, etc.) **before** wrapping with a branded constructor (`EVMAccountAddress(...)`, `makeTrackedAssetId(...)`, …). Once a value is branded, compare and pass it as that type — do **not** coerce with `String(...)`, `Number(...)`, or similar for identity checks (`===`) or Map/Set keys. Coercion hides type changes (e.g. id becoming a `number`) and creates runtime bugs that are hard to track down. Brand subtypes of `string`/`number` remain assignable to the underlying primitive where an API truly needs it (display, JSON fields typed as `string`). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2f1ac9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Build static Branding Layer + Signing Layer assets +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY host/package.json ./host/ +RUN npm ci + +COPY index.html vite.config.ts tsconfig.json tsconfig.node.json components.json ./ +COPY src ./src +COPY scripts ./scripts +COPY signer-static ./signer-static + +RUN npm run build + +# Serve with nginx +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/ /usr/share/nginx/html/ + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/README.md b/README.md index 85eabd8..a406460 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ # embedded-wallet -1Shot API's free, permissionless embedded wallet. Includes passkey based verification and OIDv4 credential managment for shared KYC + +1Shot API's free, permissionless embedded wallet. Includes passkey-based verification and OID4 credential management for shared KYC. + +This repo is the **OWS Branding Layer** for the 1Shot Wallet — a frontend-only static app (React + Vite + Tailwind + shadcn/ui) hosted at `wallet.1shotapi.com`. + +``` +Host Layer (integrator dapp) + └── This app / Branding Layer (@1shotapi/ows-wallet-utils) + └── /signer/ Signing Layer (@1shotapi/ows-signer) +``` + +## Stack + +| Path | Content | +|------|---------| +| `/` | React Branding Layer (Vite bundle) | +| `/signer/` | Static `@1shotapi/ows-signer` ES modules | + +Production deliverable: a static **nginx** Docker image (no server-side runtime). + +## Setup + +```bash +npm install +cp .env.example .env # set NGROK_AUTHTOKEN (and optional NGROK_DOMAIN) +``` + +## Develop + +```bash +npm run dev # Branding Layer + ngrok HTTPS tunnel +npm run dev:local # Branding Layer, local HTTP only +npm run dev:host # Test Host Layer (setStyle knobs + EIP-1193) +``` + +| Service | Local URL | +|---------|-----------| +| Branding (`/`) | http://localhost:5174/ | +| Signing (`/signer/`) | http://localhost:5174/signer/ | +| Test host | http://localhost:5173 | + +Passkeys need HTTPS — use the printed ngrok wallet URL as the host iframe source (`NGROK_DOMAIN` in `.env` is picked up by `dev:host`). + +Style testing: use the **Style (setStyle RPC)** panel on the test host (`host/`), not in-wallet debug UI. See [host/README.md](host/README.md). + +## Host integration + +Production iframe URL: **`https://wallet.1shotapi.com/`** + +```bash +npm install @1shotapi/ows-provider +``` + +```typescript +import { OWSProxy } from "@1shotapi/ows-provider"; + +const proxy = await OWSProxy.create(container, "https://wallet.1shotapi.com/"); + +await proxy.rpc("setStyle", { + copy: { productName: "Acme Wallet", tagline: "Powered by 1Shot" }, + theme: { primary: "oklch(0.45 0.18 250)" }, +}); + +proxy.showWallet(); +``` + +### `setStyle` (custom RPC) + +Additive merge of theme CSS variables + copy. Safe to call repeatedly. Schema is Zod-strict (unknown keys rejected). Full field list: [skills/oneshot-embedded-wallet/SKILL.md](skills/oneshot-embedded-wallet/SKILL.md). + +### Agent skill + +Integrators / coding agents: + +```bash +npx skills add 1Shot-API/embedded-wallet@oneshot-embedded-wallet +# or from a sibling clone: +npx skills add ../embedded-wallet --skill oneshot-embedded-wallet +``` + +Source: [skills/oneshot-embedded-wallet](skills/oneshot-embedded-wallet/). + +## Build + +```bash +npm run build # dist/ (branding) + dist/signer +npm run preview # preview production wallet build +``` + +## Docker + +```bash +docker build -t oneshot-wallet . +docker run --rm -p 8080:80 oneshot-wallet +# open http://localhost:8080/ +# signer at http://localhost:8080/signer/ +``` + +## Scripts + +| Script | Purpose | +|--------|---------| +| `dev` / `dev:local` | Dev server (± ngrok) | +| `dev:host` | Test Host Layer | +| `build` | Typecheck + Vite build + copy signer from `node_modules` | +| `clean` | Remove `dist/` | +| `lint` | `tsc --noEmit` | + +## Refactor roadmap + +See [roadmap.md](roadmap.md) (ShadCN + customization phases). + +## Status + +Functional Branding Layer (passkey unlock, EIP-1193, signing consent, recovery, credentials) with Phase 0–1 style foundations (`setStyle` + StyleProvider + shell). ShadCN UI migration in progress. diff --git a/components.json b/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/host/README.md b/host/README.md new file mode 100644 index 0000000..1f1be8c --- /dev/null +++ b/host/README.md @@ -0,0 +1,36 @@ +# Test Host Layer + +Local Host Layer for exercising Host ↔ Branding communication against this repo’s wallet (served at domain root). + +Adapted from OWS [`examples/host`](https://github.com/1Shot-API/open-wallet/tree/main/examples/host), plus a **Style** panel that calls `proxy.rpc("setStyle", options)`. + +## Run + +From the repo root (after `npm install`): + +```bash +# Terminal 1 — Branding Layer (prefer ngrok for passkeys) +npm run dev + +# Terminal 2 — this host +npm run dev:host +``` + +Open the printed host URL (default `http://localhost:5173`). The iframe URL comes from: + +1. `WALLET_IFRAME_URL` if set +2. else `https://{NGROK_DOMAIN}/` when `NGROK_DOMAIN` is set in root `.env` +3. else `http://localhost:5174/` + +## HTTPS (passkeys) + +If the wallet iframe is HTTPS (ngrok), the host page should also be HTTPS for a secure-context ancestor chain: + +```bash +mkcert -install +mkcert -cert-file host/certs/dev-cert.pem \ + -key-file host/certs/dev-key.pem \ + localhost 127.0.0.1 +``` + +Certs present under `host/certs/` enable HTTPS automatically; or set `HOST_HTTPS=1`. diff --git a/host/certs/.gitkeep b/host/certs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/host/components.json b/host/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/host/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/host/index.html b/host/index.html new file mode 100644 index 0000000..ba81e23 --- /dev/null +++ b/host/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + 1Shot Wallet — Host Playground + + +
+ + + diff --git a/host/package.json b/host/package.json new file mode 100644 index 0000000..6289a1a --- /dev/null +++ b/host/package.json @@ -0,0 +1,41 @@ +{ + "name": "@1shotapi/oneshot-wallet-host", + "version": "0.0.0", + "private": true, + "description": "Local Host Layer for testing 1Shot Wallet Branding (setStyle + EIP-1193)", + "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": "^0.2.1", + "@1shotapi/ows-types": "^0.1.3", + "@fontsource-variable/geist": "^5.2.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.24.0", + "radix-ui": "^1.6.2", + "react": "^19.2.7", + "react-colorful": "^5.8.0", + "react-dom": "^19.2.7", + "shadcn": "^4.13.0", + "tailwind-merge": "^3.6.0", + "viem": "^2.55.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^22.20.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "dotenv": "^16.4.7", + "tailwindcss": "^4.3.2", + "tw-animate-css": "^1.4.0", + "typescript": "~5.8.0", + "vite": "^7.0.4" + } +} diff --git a/host/public/1Shot-Icon-New.svg b/host/public/1Shot-Icon-New.svg new file mode 100644 index 0000000..d577373 --- /dev/null +++ b/host/public/1Shot-Icon-New.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/host/public/favicon.svg b/host/public/favicon.svg new file mode 100644 index 0000000..cd4a6f0 --- /dev/null +++ b/host/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/host/scripts/clean.mjs b/host/scripts/clean.mjs new file mode 100644 index 0000000..512e5e6 --- /dev/null +++ b/host/scripts/clean.mjs @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const dist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../dist"); +fs.rmSync(dist, { recursive: true, force: true }); +console.log("Removed host/dist/"); diff --git a/host/scripts/dev.mjs b/host/scripts/dev.mjs new file mode 100644 index 0000000..420266c --- /dev/null +++ b/host/scripts/dev.mjs @@ -0,0 +1,46 @@ +/** + * Start Host Layer Vite (port 5173 by default). + * Loads NGROK_DOMAIN / WALLET_IFRAME_URL from repo root .env. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; +import dotenv from "dotenv"; +import { resolveHttpsOptions, walletIframeUrl } from "../wallet-url.mjs"; + +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.HOST_PORT ?? process.env.PORT ?? 5173); + +try { + const viteServer = await createServer({ + configFile: path.join(__dirname, "../vite.config.mjs"), + server: { + port: preferredPort, + strictPort: true, + host: "0.0.0.0", + }, + }); + await viteServer.listen(); + + const https = resolveHttpsOptions({ + certsDir: path.join(__dirname, "../certs"), + }); + const scheme = https ? "https" : "http"; + const address = viteServer.httpServer?.address(); + const port = + address && typeof address === "object" ? address.port : preferredPort; + + console.log(`1Shot Wallet test host: ${scheme}://localhost:${port}`); + console.log(` Branding Layer iframe: ${walletIframeUrl()}`); + if (scheme === "http") { + console.log( + " Tip: for HTTPS wallet (ngrok) + passkeys, serve this host over HTTPS (host/certs + HOST_HTTPS=1).", + ); + } +} catch (error) { + console.error("Failed to start host:", error); + process.exit(1); +} diff --git a/host/src/App.tsx b/host/src/App.tsx new file mode 100644 index 0000000..d609a0d --- /dev/null +++ b/host/src/App.tsx @@ -0,0 +1,658 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + EWalletPresentationMode, + OWSProxy, +} from "@1shotapi/ows-provider"; +import { + EVMAccountAddress, + EVMChainId, + HexString, + type EVMTransactionHash, +} from "@1shotapi/ows-types"; +import { + createPublicClient, + custom, + encodeFunctionData, + erc20Abi, + formatUnits, + getAddress, + isAddress, + parseUnits, + type Address, + type Hex, +} from "viem"; +import { AppHeader } from "./components/AppHeader"; +import { AppSidebar, type HostMode } from "./components/AppSidebar"; +import { DesignPanel } from "./components/DesignPanel"; +import { TestPanel } from "./components/TestPanel"; +import { + HOST_CHAINS, + FOCUS_USDC_ARC, + FOCUS_USDT_BASE, + hostChainMeta, + type UsdcMode, +} from "./components/WalletActions"; +import { SidebarInset, SidebarProvider } from "./components/ui/sidebar"; +import { TooltipProvider } from "./components/ui/tooltip"; + +const USDC_DECIMALS = 6; + +function normalizeChainIdHex(value: string): EVMChainId { + return EVMChainId(`0x${BigInt(value).toString(16)}`); +} + +/** MetaMask-like branding panel size (also default in ows-provider). */ +const WALLET_SIZE_X = 360; +const WALLET_SIZE_Y = 600; + +export function App() { + const flyoutContainerRef = useRef(null); + const proxyRef = useRef(null); + /** Last setStyle payload from Design mode — re-applied after Test recreate. */ + const lastStyleRef = useRef | null>(null); + const [previewMount, setPreviewMount] = useState(null); + + const [mode, setMode] = useState("test"); + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + const [chainId, setChainId] = useState(HOST_CHAINS[0].value); + const [message, setMessage] = useState("Hello from 1Shot Wallet"); + const [usdcMode, setUsdcMode] = useState("balance"); + const [usdcDestination, setUsdcDestination] = useState(""); + const [usdcAmount, setUsdcAmount] = useState(""); + const [status, setStatus] = useState("Connecting to wallet…"); + const [statusIsError, setStatusIsError] = useState(false); + const [signature, setSignature] = useState(null); + const [usdcOutput, setUsdcOutput] = useState(null); + const [txHash, setTxHash] = useState(null); + const [txExplorerUrl, setTxExplorerUrl] = useState(null); + const [walletVisible, setWalletVisible] = useState(false); + + const reportStatus = useCallback((next: string, isError = false) => { + setStatus(next); + setStatusIsError(isError); + }, []); + + const clearUsdcOutputs = useCallback(() => { + setUsdcOutput(null); + setTxHash(null); + setTxExplorerUrl(null); + }, []); + + const refreshChainFromWallet = useCallback( + async (proxy: OWSProxy): Promise => { + const next = normalizeChainIdHex( + await proxy.ethereum.request({ method: "eth_chainId" }), + ); + setChainId(String(next)); + return next; + }, + [], + ); + + // Presentation is create-time only. Switching Test ↔ Design destroys and + // recreates the proxy against the right container (reparenting breaks Postmate). + useEffect(() => { + if (mode === "design" && !previewMount) { + return; + } + + const container = + mode === "design" ? previewMount : flyoutContainerRef.current; + if (!container) { + return; + } + + let cancelled = false; + setReady(false); + setWalletVisible(false); + reportStatus("Connecting to wallet…"); + + console.info( + "[oneshot-wallet-host] creating Branding Layer proxy", + mode, + __WALLET_IFRAME_URL__, + ); + + void (async () => { + proxyRef.current?.destroy(); + proxyRef.current = null; + container.replaceChildren(); + + try { + const proxy = await OWSProxy.create(container, __WALLET_IFRAME_URL__, { + walletSizeX: WALLET_SIZE_X, + walletSizeY: WALLET_SIZE_Y, + presentationMode: + mode === "design" + ? EWalletPresentationMode.Inline + : EWalletPresentationMode.Flyout, + }); + if (cancelled) { + proxy.destroy(); + return; + } + proxyRef.current = proxy; + + if (lastStyleRef.current) { + await proxy.rpc("setStyle", lastStyleRef.current); + } + + if (cancelled) { + proxy.destroy(); + proxyRef.current = null; + return; + } + + setReady(true); + try { + const connectedChain = await refreshChainFromWallet(proxy); + reportStatus( + mode === "design" + ? `Design preview connected on ${connectedChain}. Apply setStyle to refresh.` + : `Wallet connected on ${connectedChain}. Enter a message and click Sign.`, + ); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "Failed to read chain id", + true, + ); + } + } catch (error) { + if (cancelled) return; + console.error("[oneshot-wallet-host] failed to start", error); + reportStatus( + error instanceof Error + ? error.message + : "Failed to connect to wallet", + true, + ); + } + })(); + + return () => { + cancelled = true; + proxyRef.current?.destroy(); + proxyRef.current = null; + }; + }, [mode, previewMount, refreshChainFromWallet, reportStatus]); + + // Keep the Show/Hide label in sync when the wallet closes itself (× / menu). + useEffect(() => { + if (mode !== "test") { + return; + } + const container = flyoutContainerRef.current; + if (!container) { + return; + } + + const syncVisibility = () => { + const width = container.offsetWidth; + const height = container.offsetHeight; + // Full flyout ≈ wallet size; hidden is 0×0; RPC passthrough is 1×1. + setWalletVisible(width > 32 && height > 32); + }; + + syncVisibility(); + const observer = new ResizeObserver(syncVisibility); + observer.observe(container); + return () => observer.disconnect(); + }, [mode, ready]); + + const resolveAccount = async ( + 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 account; + }; + + const handleChainChange = (next: string) => { + const proxy = proxyRef.current; + if (!proxy) return; + const selected = EVMChainId(next as `0x${string}`); + setBusy(true); + clearUsdcOutputs(); + void (async () => { + try { + await proxy.ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: selected }], + }); + setChainId(String(selected)); + reportStatus(`Switched to ${selected}`); + } catch (error) { + await refreshChainFromWallet(proxy).catch(() => undefined); + reportStatus( + error instanceof Error ? error.message : "Chain switch failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleRefreshChain = () => { + const proxy = proxyRef.current; + if (!proxy) return; + setBusy(true); + void (async () => { + try { + const next = await refreshChainFromWallet(proxy); + reportStatus(`Chain synced: ${next}`); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "Failed to refresh chain", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleUsdcModeChange = (next: UsdcMode) => { + setUsdcMode(next); + clearUsdcOutputs(); + }; + + const handleSign = () => { + const proxy = proxyRef.current; + if (!proxy) return; + const trimmed = message.trim(); + if (!trimmed) { + reportStatus("Enter a message to sign.", true); + return; + } + + setBusy(true); + setSignature(null); + reportStatus("Requesting accounts…"); + + void (async () => { + try { + const account = await resolveAccount(proxy); + reportStatus("Approve the passkey prompt to sign…"); + const nextSignature = await proxy.ethereum.request({ + method: "personal_sign", + params: [trimmed, account], + }); + setSignature(nextSignature); + reportStatus(`Signed as ${account}`); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "Signing failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleCheckUsdcBalance = () => { + const proxy = proxyRef.current; + if (!proxy) return; + const meta = hostChainMeta(chainId); + if (!meta) { + reportStatus("USDC is not configured for this chain.", true); + clearUsdcOutputs(); + return; + } + + setBusy(true); + clearUsdcOutputs(); + reportStatus("Reading USDC balance…"); + + void (async () => { + try { + const token = getAddress(meta.usdc) as Address; + const account = await resolveAccount(proxy); + const owner = getAddress(account) as Address; + const client = createPublicClient({ + transport: custom(proxy.ethereum), + }); + + 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", + }), + ]); + + setUsdcOutput( + [ + `Contract: ${token}`, + `Account: ${owner}`, + `Name: ${name}`, + `Symbol: ${symbol}`, + `Balance: ${formatUnits(balance, decimals)} ${symbol}`, + `Raw: ${balance.toString()}`, + ].join("\n"), + ); + reportStatus( + `Balance for ${symbol}: ${formatUnits(balance, decimals)}`, + ); + } catch (error) { + reportStatus( + error instanceof Error + ? error.message + : "Failed to read USDC balance", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleSendUsdc = () => { + const proxy = proxyRef.current; + if (!proxy) return; + const meta = hostChainMeta(chainId); + if (!meta) { + reportStatus("USDC is not configured for this chain.", true); + clearUsdcOutputs(); + return; + } + + const destinationRaw = usdcDestination.trim(); + if (!isAddress(destinationRaw)) { + reportStatus("Enter a valid destination address.", true); + clearUsdcOutputs(); + return; + } + + let amount: bigint; + try { + amount = parseUnits(usdcAmount.trim(), USDC_DECIMALS); + } catch { + reportStatus("Enter a valid USDC amount.", true); + clearUsdcOutputs(); + return; + } + if (amount <= 0n) { + reportStatus("Amount must be greater than zero.", true); + clearUsdcOutputs(); + return; + } + + setBusy(true); + clearUsdcOutputs(); + reportStatus("Preparing USDC transfer…"); + + void (async () => { + try { + const account = await resolveAccount(proxy); + const to = getAddress(destinationRaw) as Address; + const data = encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [to, amount], + }) as Hex; + const activeChainId = EVMChainId(chainId as `0x${string}`); + + reportStatus("Approve the transaction in the wallet…"); + const hash = (await proxy.ethereum.request({ + method: "eth_sendTransaction", + params: [ + { + from: account, + to: EVMAccountAddress(meta.usdc as `0x${string}`), + data: HexString(data), + value: HexString("0x0"), + chainId: activeChainId, + }, + ], + })) as EVMTransactionHash; + + setUsdcOutput(`Transaction hash:\n${hash}`); + setTxHash(hash); + setTxExplorerUrl(`${meta.blockExplorerUrl}/tx/${hash}`); + reportStatus(`USDC sent. Hash ${hash}`); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "Failed to send USDC", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleUsdcAction = () => { + if (usdcMode === "send") { + handleSendUsdc(); + } else { + handleCheckUsdcBalance(); + } + }; + + const handleToggleWallet = () => { + const proxy = proxyRef.current; + if (!proxy) return; + + if (walletVisible) { + proxy.hideWallet(); + setWalletVisible(false); + reportStatus("Wallet panel hidden."); + return; + } + + proxy.showWallet(); + setWalletVisible(true); + reportStatus("Wallet panel shown. Use Hide Wallet or the wallet menu to close."); + }; + + const handleApplyStyle = async (options: Record) => { + lastStyleRef.current = options; + const proxy = proxyRef.current; + if (!proxy) { + throw new Error("Wallet not connected"); + } + await proxy.rpc("setStyle", options); + }; + + const handleFocusUsdcArc = () => { + const proxy = proxyRef.current; + if (!proxy) return; + setBusy(true); + reportStatus("Focusing wallet on Arc USDC…"); + void (async () => { + try { + await proxy.rpc("focusWallet", { + chainId: FOCUS_USDC_ARC.chainId, + assetAddress: FOCUS_USDC_ARC.assetAddress, + }); + setChainId(FOCUS_USDC_ARC.chainId); + proxy.showWallet(); + setWalletVisible(true); + reportStatus("Wallet focused on USDC (Arc Testnet)."); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "focusWallet failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleFocusUsdtBase = () => { + const proxy = proxyRef.current; + if (!proxy) return; + setBusy(true); + reportStatus("Focusing wallet on Base USDT…"); + void (async () => { + try { + await proxy.rpc("focusWallet", { + chainId: FOCUS_USDT_BASE.chainId, + assetAddress: FOCUS_USDT_BASE.assetAddress, + }); + setChainId(FOCUS_USDT_BASE.chainId); + proxy.showWallet(); + setWalletVisible(true); + reportStatus("Wallet focused on Tether (Base)."); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "focusWallet failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleUnfocusWallet = () => { + const proxy = proxyRef.current; + if (!proxy) return; + setBusy(true); + reportStatus("Restoring general wallet mode…"); + void (async () => { + try { + await proxy.rpc("unfocusWallet"); + reportStatus("Wallet restored to general mode."); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "unfocusWallet failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleAddUsdcArc = () => { + const proxy = proxyRef.current; + if (!proxy) return; + setBusy(true); + reportStatus("Requesting add Arc USDC…"); + void (async () => { + try { + await proxy.rpc("addAsset", { + chainId: FOCUS_USDC_ARC.chainId, + assetAddress: FOCUS_USDC_ARC.assetAddress, + }); + proxy.showWallet(); + setWalletVisible(true); + reportStatus("Arc USDC added to tracked assets."); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "addAsset failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const handleAddUsdtBase = () => { + const proxy = proxyRef.current; + if (!proxy) return; + setBusy(true); + reportStatus("Requesting add Base USDT…"); + void (async () => { + try { + await proxy.rpc("addAsset", { + chainId: FOCUS_USDT_BASE.chainId, + assetAddress: FOCUS_USDT_BASE.assetAddress, + }); + proxy.showWallet(); + setWalletVisible(true); + reportStatus("Base USDT added to tracked assets."); + } catch (error) { + reportStatus( + error instanceof Error ? error.message : "addAsset failed", + true, + ); + } finally { + setBusy(false); + } + })(); + }; + + const walletActionProps = { + ready, + busy, + chainId, + message, + usdcMode, + usdcDestination, + usdcAmount, + status, + statusIsError, + signature, + usdcOutput, + txHash, + txExplorerUrl, + onChainChange: handleChainChange, + onRefreshChain: handleRefreshChain, + onMessageChange: setMessage, + onUsdcModeChange: handleUsdcModeChange, + onUsdcDestinationChange: setUsdcDestination, + onUsdcAmountChange: setUsdcAmount, + onSign: handleSign, + walletVisible, + onToggleWallet: handleToggleWallet, + onUsdcAction: handleUsdcAction, + onFocusUsdcArc: handleFocusUsdcArc, + onFocusUsdtBase: handleFocusUsdtBase, + onUnfocusWallet: handleUnfocusWallet, + onAddUsdcArc: handleAddUsdcArc, + onAddUsdtBase: handleAddUsdtBase, + }; + + return ( + + + + + + {mode === "test" ? ( + + ) : ( + + )} + + {/* Flyout create() target — never reparented; Test mode only. */} +