Skip to content

Commit 46bbe3e

Browse files
committed
Add connection proof, reliable provider detection, and provider docs
- Content script runs at document_start for early injection - Injected script listens for xcp-wallet#discover for re-announcement - xcp_requestAccounts returns { accounts, proof } with auto-signed BIP-322 proof of address ownership (no user signing prompt) - Proof includes origin, nonce, timestamp, and verification hints - Add PROVIDER.md documenting all provider methods and SDK usage
1 parent 724af9f commit 46bbe3e

5 files changed

Lines changed: 349 additions & 31 deletions

File tree

PROVIDER.md

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# XCP Wallet Provider API
2+
3+
The XCP Wallet browser extension injects a provider at `window.xcpwallet` on all HTTPS pages. Websites use this provider to connect wallets, sign messages, compose transactions, and broadcast to the Bitcoin network.
4+
5+
## Detection
6+
7+
The provider is injected at `document_start` — before page scripts run. Two detection methods:
8+
9+
```js
10+
// Synchronous (provider already injected)
11+
if (window.xcpwallet) {
12+
// ready
13+
}
14+
15+
// Asynchronous (wait for injection)
16+
window.addEventListener('xcp-wallet#initialized', () => {
17+
// window.xcpwallet is now available
18+
});
19+
20+
// Request re-announcement (for SPAs that mount after injection)
21+
window.dispatchEvent(new Event('xcp-wallet#discover'));
22+
```
23+
24+
## Provider Interface
25+
26+
```ts
27+
interface XcpProvider {
28+
request(args: { method: string; params?: unknown[] }): Promise<unknown>
29+
on(event: string, handler: (...args: any[]) => void): void
30+
removeListener(event: string, handler: (...args: any[]) => void): void
31+
}
32+
```
33+
34+
## Methods
35+
36+
### Connection
37+
38+
#### `xcp_requestAccounts`
39+
40+
Connect to the wallet. Opens a popup for user approval on first connection.
41+
42+
Returns a connection proof — a BIP-322 signature proving the user controls the address. The proof message is generated by the extension (not the website) and includes the requesting origin, a random nonce, and a timestamp.
43+
44+
```js
45+
const result = await xcpwallet.request({ method: 'xcp_requestAccounts' });
46+
// {
47+
// accounts: ['bc1q...'],
48+
// proof: {
49+
// address: 'bc1q...',
50+
// message: 'xcp-wallet\norigin:https://example.com\nnonce:a1b2c3d4\nissued:1711130400',
51+
// signature: '<BIP-322 signature>',
52+
// verification: {
53+
// method: 'BIP-322',
54+
// format: 'p2wpkh' // address format used for signing
55+
// }
56+
// }
57+
// }
58+
```
59+
60+
**Proof verification (server-side):**
61+
62+
1. Parse the `message` — verify `origin` matches your domain and `issued` is recent (< 5 minutes)
63+
2. Verify the BIP-322 signature against the `address` using the `verification.format` hint
64+
3. Optionally store the `nonce` to prevent replay
65+
66+
The proof is auto-signed during connection — no additional user prompt beyond the connect approval.
67+
68+
#### `xcp_accounts`
69+
70+
Get currently connected accounts. No popup — returns empty array if not connected or wallet is locked.
71+
72+
```js
73+
const accounts = await xcpwallet.request({ method: 'xcp_accounts' });
74+
// ['bc1q...'] or []
75+
```
76+
77+
#### `xcp_disconnect`
78+
79+
Disconnect the current site.
80+
81+
```js
82+
await xcpwallet.request({ method: 'xcp_disconnect' });
83+
// true
84+
```
85+
86+
### Signing
87+
88+
All signing methods require an active connection and open a popup for user approval.
89+
90+
#### `xcp_signMessage`
91+
92+
Sign a message with the active address using BIP-322.
93+
94+
```js
95+
const result = await xcpwallet.request({
96+
method: 'xcp_signMessage',
97+
params: ['Hello, world!']
98+
});
99+
// { signature: '<base64 BIP-322 signature>' }
100+
```
101+
102+
An optional second parameter can specify the address (must match the active address):
103+
104+
```js
105+
params: ['Hello, world!', 'bc1q...']
106+
```
107+
108+
#### `xcp_signTransaction`
109+
110+
Sign a raw transaction hex.
111+
112+
```js
113+
const result = await xcpwallet.request({
114+
method: 'xcp_signTransaction',
115+
params: [{ hex: '0200000001...' }]
116+
});
117+
// { hex: '<signed transaction hex>' }
118+
119+
// Also accepts a plain string:
120+
params: ['0200000001...']
121+
```
122+
123+
#### `xcp_signPsbt`
124+
125+
Sign a PSBT (Partially Signed Bitcoin Transaction).
126+
127+
```js
128+
const result = await xcpwallet.request({
129+
method: 'xcp_signPsbt',
130+
params: [{
131+
hex: '<PSBT hex>',
132+
signInputs: { 'bc1q...': [0, 1] }, // optional: which inputs to sign
133+
sighashTypes: [0x01] // optional: sighash types
134+
}]
135+
});
136+
// { hex: '<signed PSBT hex>' }
137+
```
138+
139+
### Broadcasting
140+
141+
#### `xcp_broadcastTransaction`
142+
143+
Broadcast a signed transaction to the Bitcoin network. Includes replay protection — the same transaction cannot be broadcast twice.
144+
145+
```js
146+
const result = await xcpwallet.request({
147+
method: 'xcp_broadcastTransaction',
148+
params: ['<signed transaction hex>']
149+
});
150+
// { txid: '<64-char transaction hash>' }
151+
```
152+
153+
### Read-Only
154+
155+
#### `xcp_getBalances`
156+
157+
Get BTC and token balances for the connected address.
158+
159+
```js
160+
const result = await xcpwallet.request({ method: 'xcp_getBalances' });
161+
// { address: 'bc1q...', btc: { ... }, xcp: { ... }, tokens: [...] }
162+
```
163+
164+
#### `xcp_chainId`
165+
166+
```js
167+
await xcpwallet.request({ method: 'xcp_chainId' });
168+
// '0x0' (Bitcoin mainnet)
169+
```
170+
171+
#### `xcp_getNetwork`
172+
173+
```js
174+
await xcpwallet.request({ method: 'xcp_getNetwork' });
175+
// 'mainnet'
176+
```
177+
178+
## Events
179+
180+
```js
181+
// Account changed (address switch or connect/disconnect)
182+
xcpwallet.on('accountsChanged', (accounts) => {
183+
// accounts: string[] — empty on disconnect
184+
});
185+
186+
// Wallet disconnected this site
187+
xcpwallet.on('disconnect', () => {
188+
// Connection revoked
189+
});
190+
```
191+
192+
## SDK
193+
194+
An SDK is available for React/Next.js applications at [`lib/wallet/sdk`](https://github.com/XCP/exchange/tree/master/apps/web/src/lib/wallet/sdk). Copy the `sdk/` folder into your project:
195+
196+
```
197+
lib/wallet/
198+
├── sdk/
199+
│ ├── constants.ts — validation patterns, error codes
200+
│ ├── detect.ts — provider detection with race condition handling
201+
│ ├── errors.ts — user-friendly error messages
202+
│ ├── index.ts — public exports
203+
│ ├── provider.ts — typed XcpWallet wrapper class
204+
│ ├── types.ts — TypeScript interfaces
205+
│ └── verify.ts — connection proof validation
206+
├── wallet-context.tsx — React context provider (useWallet hook)
207+
└── useCompose.ts — compose → sign → broadcast pipeline
208+
```
209+
210+
### React usage
211+
212+
```tsx
213+
import { WalletProvider, useWallet } from '@/lib/wallet/wallet-context';
214+
215+
// Wrap your app
216+
<WalletProvider>{children}</WalletProvider>
217+
218+
// In components
219+
const { status, address, connectionProof, connect, disconnect } = useWallet();
220+
// status: 'not_detected' | 'disconnected' | 'connected'
221+
```
222+
223+
### Direct usage (non-React)
224+
225+
```ts
226+
import { detectProvider, XcpWallet } from '@/lib/wallet/sdk';
227+
228+
const provider = await detectProvider();
229+
const wallet = new XcpWallet(provider);
230+
const { accounts, proof } = await wallet.connect();
231+
```
232+
233+
### Proof verification
234+
235+
```ts
236+
import { validateProof } from '@/lib/wallet/sdk';
237+
238+
// Client-side (structural checks only)
239+
const result = await validateProof(proof, 'https://example.com', address);
240+
241+
// Server-side (with cryptographic verification)
242+
const result = await validateProof(proof, origin, address, {
243+
verifySignature: async (message, signature, addr) => {
244+
// Use your BIP-322 verification library
245+
return await verifyBIP322(message, signature, addr);
246+
}
247+
});
248+
```
249+
250+
## Security
251+
252+
- **Origin validation**: The content script provides the origin — page JavaScript cannot spoof it
253+
- **Connection proof**: BIP-322 signature proving address ownership, message format controlled by extension
254+
- **Rate limiting**: Connection, transaction, and API requests are rate-limited per origin
255+
- **Replay protection**: Broadcast transactions are tracked to prevent double-submission
256+
- **Parameter validation**: All inputs are type-checked and size-limited (max 1MB)
257+
- **CSP analysis**: Sites without Content Security Policy generate console warnings

src/entrypoints/content.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const matches = ['https://*/*', 'http://localhost/*', 'http://127.0.0.1/*'];
77

88
export default defineContentScript({
99
matches,
10+
runAt: 'document_start',
1011
async main(ctx) {
1112
/**
1213
* CRITICAL: Send "ready" signal to background immediately

src/entrypoints/injected.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,12 @@ export default defineUnlistedScript(() => {
112112
}
113113

114114
// Update accounts state from account-related responses
115-
if (data?.method === 'xcp_requestAccounts' || data?.method === 'xcp_accounts') {
116-
if (Array.isArray(data.result)) {
117-
updateAccounts(data.result);
118-
}
115+
if (data?.method === 'xcp_requestAccounts') {
116+
// New shape: { accounts: string[], proof: ... }
117+
const accts = data.result?.accounts ?? data.result;
118+
if (Array.isArray(accts)) updateAccounts(accts);
119+
} else if (data?.method === 'xcp_accounts') {
120+
if (Array.isArray(data.result)) updateAccounts(data.result);
119121
}
120122

121123
pending.resolve(data?.result);
@@ -206,6 +208,14 @@ export default defineUnlistedScript(() => {
206208
enumerable: true
207209
});
208210

211+
// Announce provider is available
209212
window.dispatchEvent(new Event('xcp-wallet#initialized'));
213+
214+
// Re-announce whenever a dApp asks — handles dApps that load before the
215+
// content script injects, or single-page apps that mount later.
216+
window.addEventListener('xcp-wallet#discover', () => {
217+
window.dispatchEvent(new Event('xcp-wallet#initialized'));
218+
});
219+
210220
console.log('XCP Wallet provider initialized');
211221
});

src/services/__tests__/providerService.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ vi.mock('@/utils/wallet/walletManager', () => ({
5656
},
5757
}));
5858
vi.mock('@/utils/storage/signMessageRequestStorage');
59+
vi.mock('@/utils/blockchain/bitcoin/messageSigner', () => ({
60+
signMessage: vi.fn().mockResolvedValue({ signature: 'mock-proof-sig', address: 'bc1qtest123' }),
61+
}));
5962
vi.mock('@/utils/storage/signPsbtRequestStorage');
6063
vi.mock('@/services/updateService');
6164
vi.mock('@/utils/provider/approvalQueue');
@@ -191,7 +194,7 @@ describe('ProviderService', () => {
191194
updateWalletAddressFormat: vi.fn(),
192195
updateWalletPinnedAssets: vi.fn(),
193196
getUnencryptedMnemonic: vi.fn(),
194-
getPrivateKey: vi.fn(),
197+
getPrivateKey: vi.fn().mockResolvedValue({ hex: 'deadbeef'.repeat(8), wif: 'test-wif', compressed: true }),
195198
removeWallet: vi.fn(),
196199
getPreviewAddressForFormat: vi.fn(),
197200
signTransaction: vi.fn(),
@@ -310,9 +313,10 @@ describe('ProviderService', () => {
310313
'https://test.com',
311314
'xcp_requestAccounts',
312315
[]
313-
);
316+
) as any;
314317

315-
expect(result).toEqual(['bc1qtest123']);
318+
expect(result.accounts).toEqual(['bc1qtest123']);
319+
expect(result.proof).toBeDefined();
316320
});
317321

318322
it('should request permission if not connected', async () => {
@@ -335,8 +339,9 @@ describe('ProviderService', () => {
335339
'wallet1' // activeWallet.id from mock (no hyphen)
336340
);
337341

338-
// Should return the accounts
339-
expect(result).toEqual(['bc1qtest123']);
342+
// Should return accounts with proof
343+
expect((result as any).accounts).toEqual(['bc1qtest123']);
344+
expect((result as any).proof).toBeDefined();
340345
});
341346

342347
});

0 commit comments

Comments
 (0)