Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .agents/skills/oneshot-embedded-wallet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ await proxy.rpc("setStyle", options);
| `theme.border` / `accent` / `accentForeground` | string | chrome |
| `theme.radius` | string | `--radius` (e.g. `"0.625rem"`) |
| `theme.fontSans` | string | `--font-sans` |
| `allowedChains` | `string[]` (hex `0x…` chain ids) | Restrict Network dropdown to these catalog chains; omit or `[]` ⇒ all enabled |
| `features.hideCloseBox` | boolean | Hide chrome Close (X); default `false`. Use in Inline hosts (e.g. extension) |
| `features.disableCredentials` | boolean | Hide Credentials tab; default `false`. Host credential flows still work |
| `features.disableDelegations` | boolean | Hide Delegations tab; default `false`. Host delegation flows still work |
| `features.allowedChains` | `string[]` (hex `0x…` chain ids) | Restrict Network dropdown to these catalog chains; omit or `[]` ⇒ all enabled |
| `copy.productName` | string | titles / chrome |
| `copy.tagline` | string | supporting line |
| `copy.connect.title` | string | connect modal title |
Expand Down Expand Up @@ -228,7 +231,7 @@ proxy.showWallet();

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).
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 where listed; USDG on Robinhood).

## Custom RPC — `createAccount`

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/ows-branding-layer/references/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Host `OWSProxy` shows a lower-right opaque flyout (no modal backdrop).

**Reference wallet path (EIP-1193):**

1. `src/ows/registerAccountConnect.ts` — `eth_accounts` / `eth_requestAccounts` (cached addresses; connect consent + `ensureReady`). Emit `wallet.providerEvents.emit("accountsChanged", [evm])` after a new connect.
1. `src/ows/registerAccountConnect.ts` — `eth_accounts` / `eth_requestAccounts` (grant-gated cache; connect consent + `ensureReady`). Emit `accountsChanged` / `connect` only after a fresh approval — not on silent reconnect.
2. `RpcHelper` for JSON-RPC reads / `wallet_switchEthereumChain` (`src/ows/demoChains.ts`, construct in `WalletProvider.tsx`). Forward `rpc.events.on("chainChanged", …)` to `wallet.providerEvents.emit("chainChanged", next)` so hosts listening on `proxy.ethereum.on("chainChanged", …)` stay in sync.
3. `SignHelper` for `personal_sign` / typed data (task 5).

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

# WXT browser extension (generated)
extension/.wxt/
extension/.output/

# Local HTTPS certs (mkcert) for host tester
host/certs/*.pem

Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Prefer **clean code over backwards compatibility**. Do not add legacy redirects,
| `/` | Branding Layer (React SPA) |
| `/signer/` | Signing Layer (`@1shotapi/ows-signer`) |
| `/create/` | First-party Host for Safari passkey create (`createAccount` RPC) |
| `/mobile/` | First-party Host PWA + WalletConnect (Inline OWSProxy ↔ Reown WalletKit) |
| `extension/` | MV3 Chrome/Firefox extension (side-panel Inline OWSProxy + MAIN-world EIP-1193 shim) |
| `src/lib/types/primitives/` | Wallet-local branded types (one file each) |
| `src/lib/types/enum/` | Domain enums (`EAssetType`, `EWalletEventKind`, …) |
| `src/lib/types/domain/` | Domain DTOs (e.g. `KnownAsset`, `TrackedAsset`, `WalletConfig`) |
Expand All @@ -21,7 +23,7 @@ Prefer **clean code over backwards compatibility**. Do not add legacy redirects,
| `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.
Test Host Layer: `host/` (`npm run dev:host`). Browser extension: `extension/` (`npm run dev:extension`) — see [`extension/README.md`](extension/README.md). Style via Host RPC `setStyle`, not in-wallet debug knobs.

### Form validation UX

Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ RUN npm ci
COPY index.html vite.config.ts tsconfig.json tsconfig.node.json components.json ./
COPY src ./src
COPY create ./create
COPY mobile ./mobile
COPY public ./public
COPY scripts ./scripts
COPY signer-static ./signer-static

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Safari create (first-party): /create/ → embeds Branding + createAccount RPC
| `/` | React Branding Layer (Vite bundle) |
| `/signer/` | Static `@1shotapi/ows-signer` ES modules |
| `/create/` | First-party Host page for Safari passkey create |
| `extension/` | MV3 Chrome/Firefox extension (MetaMask-style host) |

Production deliverable: a static **nginx** Docker image (no server-side runtime).

Expand All @@ -37,6 +38,7 @@ cp .env.example .env # set NGROK_AUTHTOKEN (and optional NGROK_DOMAIN)
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)
npm run dev:extension # Browser extension (side panel + EIP-1193 shim)
```

| Service | Local URL |
Expand Down
39 changes: 14 additions & 25 deletions create/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,19 @@ function readHandoff(): string | null {
return value && value.length > 0 ? value : null;
}

function notifyOpener(message: AccountCreateHandoffMessage): void {
const opener = window.opener;
if (!opener || opener.closed) {
console.warn("[create] cannot notify opener", {
hasOpener: Boolean(opener),
closed: opener?.closed,
type: message.type,
});
return;
}
console.info("[create] postMessage → opener", {
function notifyWallet(message: AccountCreateHandoffMessage): void {
const opener = window.opener as Window | null;
const hasOpener = Boolean(opener) && !opener!.closed;
console.info("[create] notify wallet", {
type: message.type,
handoff: message.handoff,
hasOpener,
credentialId: message.credentialId ? "(present)" : undefined,
cosePublicKey: message.cosePublicKey ? "(present)" : undefined,
targetOrigin: window.location.origin,
});
postAccountCreateHandoff(opener, message);
// Always BroadcastChannel (same-origin Branding iframe). Also postMessage
// when opener exists — extension-opened tabs often have opener === null.
postAccountCreateHandoff(hasOpener ? opener : null, message);
}

async function closeOrPrompt(): Promise<void> {
Expand All @@ -71,18 +66,12 @@ async function main(): Promise<void> {
return;
}

if (!window.opener) {
setStatus(
"This page must be opened from the wallet. Return to the app and try Create again.",
true,
);
return;
}

const opener = window.opener as Window | null;
const hasOpener = Boolean(opener) && !opener!.closed;
console.info("[create] start", {
handoff,
origin: window.location.origin,
hasOpener: true,
hasOpener,
});

const walletUrl = new URL("/", window.location.origin).href;
Expand Down Expand Up @@ -120,7 +109,7 @@ async function main(): Promise<void> {
hasCosePublicKey: true,
});

notifyOpener({
notifyWallet({
type: OWS_ACCOUNT_CREATED,
handoff,
credentialId: result.credentialId,
Expand All @@ -137,7 +126,7 @@ async function main(): Promise<void> {

console.warn("[create] createAccount failed", { cancelled, message, error });

notifyOpener({
notifyWallet({
type: cancelled
? OWS_ACCOUNT_CREATE_CANCELLED
: OWS_ACCOUNT_CREATE_FAILED,
Expand All @@ -155,7 +144,7 @@ main().catch((error: unknown) => {
console.error("[create] failed", error);
const handoff = readHandoff();
if (handoff) {
notifyOpener({
notifyWallet({
type: OWS_ACCOUNT_CREATE_FAILED,
handoff,
message: error instanceof Error ? error.message : String(error),
Expand Down
148 changes: 148 additions & 0 deletions extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 1Shot Wallet browser extension

MetaMask-style host for the 1Shot Branding Layer: a thin MAIN-world EIP-1193 / EIP-6963 shim on the dApp page, with `OWSProxy` hosted **Inline** in a Chrome side panel / Firefox sidebar.

This avoids page `frame-src` CSP, hostile host CSS, and per-dApp third-party storage partitions.

## Requirements

- Node.js 22+
- Chrome 116+ (side panel + `scripting.executeScript` MAIN world)
- Firefox 128+ (MV3 + MAIN world + sidebar)

## Develop

From the embedded-wallet repo root:

```bash
npm install
npm run dev:extension # Chrome HMR → extension/dist/chrome-mv3-dev
```

### Firefox (important)

Do **not** use Vite serve (`firefox-mv3-dev`) for day-to-day Firefox testing.
Firefox’s extension CSP blocks Vite’s `eval` / `wss://localhost:3000` HMR, so
sidepanel/options modules often never run (buttons appear dead, sidebar stuck on
“Loading wallet…”).

```bash
npm run build:firefox -w @1shotapi/oneshot-wallet-extension
# same as: npm run dev:firefox -w @1shotapi/oneshot-wallet-extension
# → extension/dist/firefox-mv3 (self-contained CSS/JS)
```

Then `about:debugging` → Load Temporary Add-on →
`extension/dist/firefox-mv3/manifest.json`. After code changes, rebuild and
click **Reload** on the temporary add-on (or remove/re-add).

Legacy Vite serve (broken under Firefox CSP) remains as
`npm run dev:firefox:vite -w @1shotapi/oneshot-wallet-extension` →
`firefox-mv3-dev` only.

### Point at a local / ngrok wallet

Open the extension **Settings** and set **Wallet iframe URL** to a full URL
(e.g. `https://immune-sheep-light.ngrok-free.app/`). Default is
`https://wallet.1shotapi.com/`.

Load the unpacked extension:

- **Chrome (dev):** `chrome://extensions` → Load unpacked → `extension/dist/chrome-mv3-dev`
- **Chrome (stable build):** `extension/dist/chrome-mv3`
- **Firefox (recommended):** load `extension/dist/firefox-mv3/manifest.json`
(from `build:firefox` / `dev:firefox`)
- **Firefox Vite-dev (unsupported):** `extension/dist/firefox-mv3-dev` — CSS may
load after CSP tweaks, but entrypoint JS regularly dies; prefer `firefox-mv3`.

## Use

The **side panel / sidebar** is the wallet UI (Branding iframe). It does **not**
automatically put a provider on the dApp. Injection is explicit:

1. Open the dApp tab (or the host **Injected** playground).
2. In the extension side panel top bar, click **Inject** (or the toolbar
popup → **Inject on this page**). Grant host permission if prompted.
3. Optionally **Always** / **Always inject on this origin** for reload auto-inject.
4. Confirm in the page console:
- `window.ethereum?.is1Shot === true`, and/or
- EIP-6963 announce for `com.1shotapi.wallet`
5. Connect in the dApp. If MetaMask is also installed, enable **Prefer 1Shot as
window.ethereum** in extension Settings, or pick 1Shot from an EIP-6963 wallet
list (Uniswap’s shortcut strip may only highlight MetaMask).

Approve / sign stays in the side panel.

**Firefox note:** Keep the side panel open while connecting. The extension only
opens the sidebar when it has no live panel connection — calling
`sidebarAction.open()` on an already-open panel can reload it and drop Connect RPCs.

### Easiest local test

```bash
npm run dev:host
npm run build -w @1shotapi/oneshot-wallet-extension # reload the temp add-on
```

Open the host → sidebar mode **Injected** → extension **Inject** on that tab →
**Connect**. That page never creates `OWSProxy`; it only talks to the injected
provider.


## Build / pack

```bash
npm run build:extension # Chrome MV3 → extension/dist/chrome-mv3
npm run build:firefox -w @1shotapi/oneshot-wallet-extension
npm run pack:chrome -w @1shotapi/oneshot-wallet-extension # zip for CWS
npm run pack:firefox -w @1shotapi/oneshot-wallet-extension # zip for AMO
```

### Chrome Web Store (unlisted / test)

1. `npm run pack:chrome -w @1shotapi/oneshot-wallet-extension`
2. Upload the zip from `extension/.output/` (WXT zip output) in the [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole).
3. Publish as **Unlisted** for testers.

### Firefox AMO self-distribution

1. `npm run pack:firefox -w @1shotapi/oneshot-wallet-extension`
2. Sign with [AMO](https://addons.mozilla.org/developers/) “On your own” / self-distributed listing (JWT API credentials), or upload for signing and download the `.xpi`.
3. Extension id: `wallet-extension@1shotapi.com` (see `browser_specific_settings.gecko`).

## Architecture

```
dApp MAIN world content script service worker side panel
───────────────── ────────────── ────────────── ──────────
inpage.js shim ←post→ bridge ←msg→ router / queue ←port→ OWSProxy Inline
window.ethereum openWalletUi() Branding iframe
EIP-6963 announce allowlist inject
```

- `@1shotapi/ows-provider` is **only** bundled into the side panel page.
- MAIN-world inject uses `chrome.scripting.executeScript({ world: "MAIN" })` so page `script-src` CSP does not block the shim.

## Privacy

- No page scraping or analytics beyond what the Branding iframe already does for wallet UX.
- Scripts inject only after user action or for allowlisted origins the user added.
- Use **Always** (side panel) so the provider re-injects after a dApp tab refresh.
One-shot **Inject** does not survive reload — without Always you must Inject again
before Connect will find 1Shot.
- Optional host permissions are requested per origin when injecting.

## Limitations

- Closing the side panel destroys the `OWSProxy` session; the next RPC reopens the panel and recreates the proxy (wallet storage under the extension top-level partition should restore accounts).
- Extension page CSP must allow framing your wallet URL (`https:` and localhost are allowed in this test build).
- Safari `/create/` handoff still opens from Branding inside the panel iframe — allow popups from the extension page if prompted. The create tab does not require `window.opener` (Safari Web Extension often omits it); the result returns on a same-origin `BroadcastChannel`.

## Manual test checklist

- [ ] Inject on a page with strict `frame-src 'self'` — Connect Wallet still works (iframe is only in the side panel).
- [ ] MetaMask installed: EIP-6963 lists 1Shot; Prefer 1Shot toggles `window.ethereum`.
- [ ] MetaMask absent: `window.ethereum` is 1Shot after inject.
- [ ] Allowlist origin → reload → shim present without clicking Inject.
- [ ] Settings wallet URL → ngrok Branding → panel loads and unlock/connect works.
- [ ] `eth_requestAccounts` / send opens and focuses the side panel.
Loading