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
49 changes: 46 additions & 3 deletions .agents/skills/oneshot-embedded-wallet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 configure / switchChain / focusWallet / addAsset / createAccount / onramp / bridge for
or custom RPC such as configure / switchChain / focusWallet / addAsset / createAccount / onramp / bridge / getUpgraded / requestCancelDelegations for
theming, host-driven focus mode, tracked assets, and first-party Safari create.
license: MIT
metadata:
Expand Down Expand Up @@ -320,6 +320,25 @@ await proxy.rpc("onramp", {

Returns `{ ok: true }` when the user closes the onramp view. The Relayer holds the Circle kit key; the browser only receives a single-use session. When Branding is nested in a Host iframe, Buy prefers AppKit `openWindow`; top-level Branding uses `mountIframe`. Override with `localStorage.setItem("circlePopup", "true"|"false")`.

## Custom RPC — `getUpgraded`

Read-only EIP-7702 upgrade check for the unlocked EOA on one chain. Hosts can call this before `wallet_requestExecutionPermissions` to prepare the user (onramp for USDC, expect an activation fee, etc.). No flyout.

```typescript
const status = await proxy.rpc("getUpgraded", {
chainId: "0x2105", // Base — or "0x1", "Bitcoin", …
});
// { upgraded: true, codeAddress: "0x…" }
// { upgraded: false }
// { upgraded: false, error: "Chain Bitcoin is not an EVM chain" }
```

| Method | Params | Behavior |
|--------|--------|----------|
| `getUpgraded` | `{ chainId: string }` OWS id (`0x…` / `Bitcoin` / `BitcoinTestnet`) | On-chain `getCode` for the unlocked EOA; `upgraded` when delegated to the StatelessDelegator impl |

Returns `{ upgraded: boolean, codeAddress?: EVMContractAddress, error?: string }`. Non-EVM chain ids and getCode failures soft-fail via `error` (do not throw). Locked wallet throws `"Wallet is locked — unlock before getUpgraded"`.

## Custom RPC — `bridge`

Opens the gasless CCTP USDC bridge (native TokenMessengerV2 + Circle Forwarding Service, submitted through the 1Shot relayer). Locked wallet → error. Cancel before success → `OwsUserRejectedError`. Execute failure → thrown error. Flyout closes when the RPC settles (`requestDisplay` / `hide`). Omit `sourceChainId` to use the session chain.
Expand All @@ -345,6 +364,30 @@ Returns `{ ok: true, burnTxHash, forwardTxHash? }` when the bridge succeeds (des

Product analytics: `BridgeOpened`, `BridgeCompleted`, `BridgeFailed`, `BridgeCancelled` (burn submit still also emits `TransactionSubmitted*`).

## Custom RPC — `requestCancelDelegations`

Batch on-chain revoke for permissions this host previously received from `wallet_requestExecutionPermissions`. Pass the grant response `context` values as `permissionContexts`. Opens the cancel confirm modal (same UI as the Delegations tab). Same-chain contexts are disabled in one relayer transaction; multi-chain selections submit one batched send per chain.

Only vault rows whose `hostDomain` matches the calling host are accepted. Unknown contexts or permissions granted to another host throw `OwsInvalidParamsError` before the flyout opens.

```typescript
// After wallet_requestExecutionPermissions → responses[].context
const result = await proxy.rpc("requestCancelDelegations", {
permissionContexts: [
responses[0].context,
responses[1].context, // same or different chain — one modal
],
});
// { transactionHashes: ["0x…", …] } // one hash per unique chain
// { transactionHashes: null } // user skipped on-chain (vault delete only)
```

| Method | Params | Behavior |
|--------|--------|----------|
| `requestCancelDelegations` | `{ permissionContexts: HexString[] }` (min 1) | Domain-scoped batch cancel; flyout until grant/reject |

User reject → `OwsUserRejectedError`. Prefer this over `wallet_revokeExecutionPermission` when canceling multiple grants or when the host should not touch permissions it did not receive.

## Other Host APIs

| API | Use |
Expand All @@ -353,7 +396,7 @@ Product analytics: `BridgeOpened`, `BridgeCompleted`, `BridgeFailed`, `BridgeCan
| `proxy.ethereum.on` / `removeListener` | Branding→Host EIP-1193 notifications (`chainChanged`, `accountsChanged` via `ows:eip1193`) |
| `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 (`configure`, `switchChain`, `getChainId`, `focusWallet`, `unfocusWallet`, `addAsset`, `createAccount`, `onramp`, `bridge`, …) |
| `proxy.rpc(method, params)` | Custom Branding RPC (`configure`, `switchChain`, `getChainId`, `focusWallet`, `unfocusWallet`, `addAsset`, `createAccount`, `onramp`, `getUpgraded`, `bridge`, `requestCancelDelegations`, …) |
| `proxy.analytics.on(listener)` / `.on(name, listener)` / `.off(listener)` | Branding→Host product analytics (`ows:analytics`) |

Subscribe so in-wallet chain/account changes update host UI without polling:
Expand Down Expand Up @@ -396,7 +439,7 @@ The same rich payload is POSTed fire-and-forget to `POST /wallet/product-events`
1Shot relayer. The local Host (`host/`) and marketing [wallet playground](https://www.1shotapi.com/playground)
include a live Analytics panel fed by `proxy.analytics.on` (filter by `name`).

EIP-7715 host RPCs: `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission`, `wallet_getSupportedExecutionPermissions`, `wallet_getGrantedExecutionPermissions` (grant consent and on-chain revoke are wallet-driven).
EIP-7715 host RPCs: `wallet_requestExecutionPermissions`, `wallet_revokeExecutionPermission` (single `permissionContext`), `wallet_getSupportedExecutionPermissions`, `wallet_getGrantedExecutionPermissions`. For batch / domain-scoped cancel from a host, use custom RPC `requestCancelDelegations` with the grant `context` values.

### Supported permission types

Expand Down
90 changes: 67 additions & 23 deletions host/src/components/WalletActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface IWalletActionsProps {
onRequestDelegation: () => void;
onRequestLiFiDelegation: () => void;
onCancelDelegation: (id: string) => void;
onCancelSelectedDelegations: (ids: string[]) => void;
onGetSupportedPermissions: () => void;
onGetGrantedPermissions: () => void;
}
Expand Down Expand Up @@ -146,10 +147,14 @@ export function WalletActions({
onRequestDelegation,
onRequestLiFiDelegation,
onCancelDelegation,
onCancelSelectedDelegations,
onGetSupportedPermissions,
onGetGrantedPermissions,
}: IWalletActionsProps) {
const meta = hostChainMeta(chainId);
const [selectedGrantIds, setSelectedGrantIds] = useState<Set<string>>(
() => new Set(),
);
const [addAssetChainId, setAddAssetChainId] = useState<string>(
FOCUS_USDT_BASE.chainId,
);
Expand Down Expand Up @@ -681,30 +686,69 @@ export function WalletActions({

{sessionGrants.length > 0 ? (
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{sessionGrants.map((grant) => (
<li
key={grant.id}
className="border-border flex flex-col gap-2 rounded-md border p-2"
>
<div className="flex items-start justify-between gap-2">
<p className="text-foreground m-0 text-xs font-medium">
{grant.summary}
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={!ready || busy}
onClick={() => onCancelDelegation(grant.id)}
>
Cancel
</Button>
</div>
<pre className="border-border bg-muted/40 m-0 max-h-40 overflow-auto rounded-md border p-2 font-mono text-[0.65rem] break-all whitespace-pre-wrap">
{grant.json}
</pre>
{sessionGrants.map((grant) => {
const checked = selectedGrantIds.has(grant.id);
return (
<li
key={grant.id}
className="border-border flex flex-col gap-2 rounded-md border p-2"
>
<div className="flex items-start gap-2">
<label className="flex min-w-0 flex-1 cursor-pointer items-start gap-2">
<input
type="checkbox"
className="border-input mt-0.5 size-4 shrink-0 rounded border"
checked={checked}
disabled={!ready || busy}
onChange={() => {
setSelectedGrantIds((prev) => {
const next = new Set(prev);
if (next.has(grant.id)) next.delete(grant.id);
else next.add(grant.id);
return next;
});
}}
/>
<p className="text-foreground m-0 text-xs font-medium">
{grant.summary}
</p>
</label>
<Button
type="button"
size="sm"
variant="outline"
disabled={!ready || busy}
onClick={() => onCancelDelegation(grant.id)}
>
Cancel
</Button>
</div>
<pre className="border-border bg-muted/40 m-0 max-h-40 overflow-auto rounded-md border p-2 font-mono text-[0.65rem] break-all whitespace-pre-wrap">
{grant.json}
</pre>
</li>
);
})}
{selectedGrantIds.size > 0 ? (
<li className="flex items-center justify-between gap-2 pt-1">
<p className="text-muted-foreground m-0 text-xs">
{selectedGrantIds.size} selected
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={!ready || busy}
onClick={() => {
const ids = [...selectedGrantIds];
setSelectedGrantIds(new Set());
onCancelSelectedDelegations(ids);
}}
>
Cancel selected
</Button>
</li>
))}
) : null}
</ul>
) : null}

Expand Down
72 changes: 72 additions & 0 deletions host/src/components/WalletConfiguratorTextTabWalletSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,78 @@ export function WalletConfiguratorTextTabWalletSections({
patch("cancelDelegationSkipOnchainAcknowledgement", value)
}
/>
<TextField
id="activate-offline-permissions-title"
label="Activate offline permissions title"
value={form.activateOfflinePermissionsTitle}
onChange={(value) =>
patch("activateOfflinePermissionsTitle", value)
}
/>
<TextField
id="activate-offline-permissions-body"
label="Activate offline permissions body"
value={form.activateOfflinePermissionsBody}
onChange={(value) =>
patch("activateOfflinePermissionsBody", value)
}
/>
<TextField
id="activate-offline-permissions-chains-label"
label="Activate offline permissions chains label"
value={form.activateOfflinePermissionsChainsLabel}
onChange={(value) =>
patch("activateOfflinePermissionsChainsLabel", value)
}
/>
<TextField
id="activate-offline-permissions-pay-from-label"
label="Activate offline permissions pay from label"
value={form.activateOfflinePermissionsPayFromLabel}
onChange={(value) =>
patch("activateOfflinePermissionsPayFromLabel", value)
}
/>
<TextField
id="activate-offline-permissions-fee-label"
label="Activate offline permissions fee label"
value={form.activateOfflinePermissionsFeeLabel}
onChange={(value) =>
patch("activateOfflinePermissionsFeeLabel", value)
}
/>
<TextField
id="activate-offline-permissions-insufficient-balance-error"
label="Activate offline permissions insufficient balance error"
value={form.activateOfflinePermissionsInsufficientBalanceError}
onChange={(value) =>
patch("activateOfflinePermissionsInsufficientBalanceError", value)
}
/>
<TextField
id="activate-offline-permissions-no-usdc-error"
label="Activate offline permissions no USDC error"
value={form.activateOfflinePermissionsNoUsdcError}
onChange={(value) =>
patch("activateOfflinePermissionsNoUsdcError", value)
}
/>
<TextField
id="activate-offline-permissions-confirm"
label="Activate offline permissions confirm"
value={form.activateOfflinePermissionsConfirm}
onChange={(value) =>
patch("activateOfflinePermissionsConfirm", value)
}
/>
<TextField
id="activate-offline-permissions-reject"
label="Activate offline permissions reject"
value={form.activateOfflinePermissionsReject}
onChange={(value) =>
patch("activateOfflinePermissionsReject", value)
}
/>
<TextField
id="transfer-tokens-sent-title"
label="Transfer sent title"
Expand Down
44 changes: 40 additions & 4 deletions host/src/hooks/useHostTestActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,17 +902,52 @@ export function useHostTestActions({
try {
proxy.showWallet();
setWalletVisible(true);
await proxy.ethereum.request({
method: "wallet_revokeExecutionPermission",
params: [{ permissionContext: grant.response.context }],
await proxy.rpc("requestCancelDelegations", {
permissionContexts: [grant.response.context],
});
setSessionGrants((prev) => prev.filter((g) => g.id !== id));
reportStatus("Permission canceled on-chain and removed from memory.");
} catch (error) {
reportStatus(
error instanceof Error
? error.message
: "revokeExecutionPermission failed",
: "requestCancelDelegations failed",
true,
);
} finally {
setBusy(false);
}
})();
};

const handleCancelSelectedDelegations = (ids: string[]) => {
const proxy = proxyRef.current;
if (!proxy) return;
const grants = sessionGrants.filter((g) => ids.includes(g.id));
if (grants.length === 0) {
reportStatus("No matching grants selected.", true);
return;
}
setBusy(true);
setDelegationsOutput(null);
reportStatus(`Canceling ${grants.length} EIP-7715 permission(s)…`);
void (async () => {
try {
proxy.showWallet();
setWalletVisible(true);
await proxy.rpc("requestCancelDelegations", {
permissionContexts: grants.map((g) => g.response.context),
});
const idSet = new Set(ids);
setSessionGrants((prev) => prev.filter((g) => !idSet.has(g.id)));
reportStatus(
`${grants.length} permission(s) canceled on-chain and removed from memory.`,
);
} catch (error) {
reportStatus(
error instanceof Error
? error.message
: "requestCancelDelegations failed",
true,
);
} finally {
Expand Down Expand Up @@ -1029,6 +1064,7 @@ export function useHostTestActions({
onRequestDelegation: handleRequestDelegation,
onRequestLiFiDelegation: handleRequestLiFiDelegation,
onCancelDelegation: handleCancelDelegation,
onCancelSelectedDelegations: handleCancelSelectedDelegations,
onGetSupportedPermissions: handleGetSupportedPermissions,
onGetGrantedPermissions: handleGetGrantedPermissions,
};
Expand Down
Loading
Loading