@meshconnect/uwc-bitcoin 0.2.3 → 0.2.4-snapshot.c3e9146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +20 -7
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapters/okx.d.ts +50 -0
- package/dist/adapters/okx.d.ts.map +1 -0
- package/dist/adapters/okx.js +70 -0
- package/dist/adapters/okx.js.map +1 -0
- package/dist/adapters/wallet-standard.d.ts +88 -0
- package/dist/adapters/wallet-standard.d.ts.map +1 -0
- package/dist/adapters/wallet-standard.js +118 -0
- package/dist/adapters/wallet-standard.js.map +1 -0
- package/dist/bitcoin-namespace.d.ts +20 -3
- package/dist/bitcoin-namespace.d.ts.map +1 -1
- package/dist/bitcoin-namespace.js +39 -2
- package/dist/bitcoin-namespace.js.map +1 -1
- package/dist/encoding.d.ts +12 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +46 -0
- package/dist/encoding.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/adapter.ts +25 -7
- package/src/adapters/okx.test.ts +140 -0
- package/src/adapters/okx.ts +116 -0
- package/src/adapters/wallet-standard.test.ts +148 -0
- package/src/adapters/wallet-standard.ts +194 -0
- package/src/bitcoin-namespace.test.ts +92 -1
- package/src/bitcoin-namespace.ts +46 -3
- package/src/encoding.ts +47 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meshconnect/uwc-bitcoin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4-snapshot.c3e9146",
|
|
4
4
|
"description": "Mesh-owned Bitcoin (bip122) namespace module for Universal Wallet Connector — network-specific logic (PSBT, address/network handling, mempool broadcast) with pluggable transport adapters (Wallet Standard bitcoin:* and WalletConnect bip122), consumed by the injected and walletConnect transport connectors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"src"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@meshconnect/uwc-
|
|
19
|
+
"@meshconnect/uwc-constants": "0.7.4-snapshot.c3e9146",
|
|
20
|
+
"@meshconnect/uwc-types": "0.26.1-snapshot.c3e9146"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"typescript": "^5.9.3"
|
package/src/adapter.ts
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BitcoinAccount,
|
|
3
|
+
BitcoinPsbtSignRequest,
|
|
4
|
+
BitcoinSendTransferRequest,
|
|
3
5
|
BitcoinSignMessageParams,
|
|
4
|
-
SignatureType
|
|
6
|
+
SignatureType,
|
|
7
|
+
TransactionResult
|
|
5
8
|
} from '@meshconnect/uwc-types'
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* The seam between the Bitcoin namespace module and a single transport.
|
|
9
12
|
*
|
|
10
|
-
* A concrete adapter
|
|
13
|
+
* A concrete adapter is constructed by its transport bound to an
|
|
11
14
|
* already-connected provider/session — Wallet Standard `bitcoin:*` for the
|
|
12
15
|
* injected transport, WalletConnect bip122 for the WalletConnect transport —
|
|
13
16
|
* so the port carries no `connect`/provider argument: connecting is the
|
|
14
17
|
* transport's job. Each adapter converts between these transport-agnostic
|
|
15
|
-
* domain shapes and its wallet's native interface (e.g. hex ↔
|
|
18
|
+
* domain shapes and its wallet's native interface (e.g. hex/base64 strings ↔
|
|
19
|
+
* `Uint8Array`).
|
|
16
20
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* `getAccounts` + `signMessage` are the always-present verify-flow methods.
|
|
22
|
+
* `signPsbt` and `sendTransfer` are optional because they map to distinct wallet
|
|
23
|
+
* paths — a Wallet Standard wallet signs a PSBT (Path B); a WalletConnect wallet
|
|
24
|
+
* does a high-level `sendTransfer` (Path A) — and not every adapter offers both.
|
|
21
25
|
*/
|
|
22
26
|
export interface BitcoinTransportAdapter {
|
|
23
27
|
/** Enumerate the bip122 accounts available on the connected session. */
|
|
@@ -26,6 +30,20 @@ export interface BitcoinTransportAdapter {
|
|
|
26
30
|
/** Sign a message with the given account. */
|
|
27
31
|
signMessage(params: BitcoinSignMessageParams): Promise<SignatureType>
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Sign a PSBT (Path B). Returns the wallet-signed PSBT, base64-encoded — the
|
|
35
|
+
* wallet does NOT broadcast; finalizing + broadcasting is a separate step.
|
|
36
|
+
* Optional: only adapters whose wallet exposes PSBT signing implement it.
|
|
37
|
+
*/
|
|
38
|
+
signPsbt?(request: BitcoinPsbtSignRequest): Promise<string>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* High-level transfer (Path A): the wallet does coin selection, fee, signing,
|
|
42
|
+
* and broadcast, returning a txid. Optional: only adapters whose transport
|
|
43
|
+
* offers it (e.g. WalletConnect `sendTransfer`) implement it.
|
|
44
|
+
*/
|
|
45
|
+
sendTransfer?(request: BitcoinSendTransferRequest): Promise<TransactionResult>
|
|
46
|
+
|
|
29
47
|
/** Release any session resources the adapter holds. Optional. */
|
|
30
48
|
disconnect?(): Promise<void>
|
|
31
49
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { WalletConnectorError } from '@meshconnect/uwc-types'
|
|
3
|
+
import { OkxBitcoinAdapter, type OkxBitcoinProvider } from './okx'
|
|
4
|
+
import {
|
|
5
|
+
base64ToBytes,
|
|
6
|
+
bytesToBase64,
|
|
7
|
+
bytesToHex,
|
|
8
|
+
hexToBytes
|
|
9
|
+
} from '../encoding'
|
|
10
|
+
|
|
11
|
+
const account = { address: 'bc1qokx', publicKey: '02aabbcc' }
|
|
12
|
+
|
|
13
|
+
/** A fake OKX injected bitcoin provider wired via vi mocks. */
|
|
14
|
+
const fakeProvider = (
|
|
15
|
+
overrides: Partial<OkxBitcoinProvider> = {}
|
|
16
|
+
): OkxBitcoinProvider => ({
|
|
17
|
+
requestAccounts: vi.fn().mockResolvedValue([account.address]),
|
|
18
|
+
getAccounts: vi.fn().mockResolvedValue([account.address]),
|
|
19
|
+
getPublicKey: vi.fn().mockResolvedValue(account.publicKey),
|
|
20
|
+
signMessage: vi.fn().mockResolvedValue('c2lnbmF0dXJl'),
|
|
21
|
+
signPsbt: vi.fn().mockResolvedValue('abcdef01'),
|
|
22
|
+
on: vi.fn(),
|
|
23
|
+
removeListener: vi.fn(),
|
|
24
|
+
...overrides
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('OkxBitcoinAdapter', () => {
|
|
28
|
+
it('getAccounts prompts via requestAccounts and pairs the active public key', async () => {
|
|
29
|
+
const provider = fakeProvider()
|
|
30
|
+
const accounts = await new OkxBitcoinAdapter(provider).getAccounts()
|
|
31
|
+
|
|
32
|
+
expect(provider.requestAccounts).toHaveBeenCalledOnce()
|
|
33
|
+
expect(accounts).toEqual([{ address: 'bc1qokx', publicKey: '02aabbcc' }])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('getAccounts tolerates a getPublicKey failure (empty key, not a throw)', async () => {
|
|
37
|
+
const provider = fakeProvider({
|
|
38
|
+
getPublicKey: vi.fn().mockRejectedValue(new Error('locked'))
|
|
39
|
+
})
|
|
40
|
+
const accounts = await new OkxBitcoinAdapter(provider).getAccounts()
|
|
41
|
+
expect(accounts).toEqual([{ address: 'bc1qokx', publicKey: '' }])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('signMessage maps bip322 → bip322-simple and passes the base64 signature through', async () => {
|
|
45
|
+
const provider = fakeProvider()
|
|
46
|
+
const adapter = new OkxBitcoinAdapter(provider)
|
|
47
|
+
|
|
48
|
+
const result = await adapter.signMessage({
|
|
49
|
+
account: { address: account.address, publicKey: account.publicKey },
|
|
50
|
+
message: 'verify me',
|
|
51
|
+
protocol: 'bip322'
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(provider.signMessage).toHaveBeenCalledWith(
|
|
55
|
+
'verify me',
|
|
56
|
+
'bip322-simple'
|
|
57
|
+
)
|
|
58
|
+
expect(result).toEqual({ type: 'standard', signature: 'c2lnbmF0dXJl' })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('signMessage defaults to ecdsa when no protocol is given', async () => {
|
|
62
|
+
const provider = fakeProvider()
|
|
63
|
+
await new OkxBitcoinAdapter(provider).signMessage({
|
|
64
|
+
account: { address: account.address, publicKey: account.publicKey },
|
|
65
|
+
message: 'hi'
|
|
66
|
+
})
|
|
67
|
+
expect(provider.signMessage).toHaveBeenCalledWith('hi', 'ecdsa')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('signMessage throws when the provider returns no signature', async () => {
|
|
71
|
+
const provider = fakeProvider({
|
|
72
|
+
signMessage: vi.fn().mockResolvedValue('')
|
|
73
|
+
})
|
|
74
|
+
await expect(
|
|
75
|
+
new OkxBitcoinAdapter(provider).signMessage({
|
|
76
|
+
account: { address: account.address, publicKey: account.publicKey },
|
|
77
|
+
message: 'hi'
|
|
78
|
+
})
|
|
79
|
+
).rejects.toBeInstanceOf(WalletConnectorError)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('signPsbt converts base64 → hex for OKX and hex → base64 on the way back', async () => {
|
|
83
|
+
// Round-trip fixture: a base64 PSBT whose hex the mock echoes back signed.
|
|
84
|
+
const psbtBytes = new Uint8Array([0x70, 0x73, 0x62, 0x74, 0xff])
|
|
85
|
+
const psbtB64 = bytesToBase64(psbtBytes)
|
|
86
|
+
const signedBytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
|
87
|
+
const provider = fakeProvider({
|
|
88
|
+
signPsbt: vi.fn().mockResolvedValue(bytesToHex(signedBytes))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const result = await new OkxBitcoinAdapter(provider).signPsbt({
|
|
92
|
+
from: account.address,
|
|
93
|
+
psbt: psbtB64
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// OKX received the PSBT as HEX (autoFinalized:false = sign-only).
|
|
97
|
+
expect(provider.signPsbt).toHaveBeenCalledWith(bytesToHex(psbtBytes), {
|
|
98
|
+
autoFinalized: false
|
|
99
|
+
})
|
|
100
|
+
// ...and we returned it as base64.
|
|
101
|
+
expect(result).toBe(bytesToBase64(signedBytes))
|
|
102
|
+
expect(base64ToBytes(result)).toEqual(signedBytes)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('signPsbt throws when the provider returns no PSBT', async () => {
|
|
106
|
+
const provider = fakeProvider({
|
|
107
|
+
signPsbt: vi.fn().mockResolvedValue('')
|
|
108
|
+
})
|
|
109
|
+
await expect(
|
|
110
|
+
new OkxBitcoinAdapter(provider).signPsbt({
|
|
111
|
+
from: account.address,
|
|
112
|
+
psbt: bytesToBase64(new Uint8Array([1, 2]))
|
|
113
|
+
})
|
|
114
|
+
).rejects.toBeInstanceOf(WalletConnectorError)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('disconnect resolves (OKX has no disconnect RPC)', async () => {
|
|
118
|
+
await expect(
|
|
119
|
+
new OkxBitcoinAdapter(fakeProvider()).disconnect()
|
|
120
|
+
).resolves.toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('hexToBytes', () => {
|
|
125
|
+
it('round-trips with bytesToHex, tolerating a 0x / 0X prefix', () => {
|
|
126
|
+
const bytes = new Uint8Array([0x00, 0x0f, 0xa0, 0xff])
|
|
127
|
+
expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes)
|
|
128
|
+
expect(hexToBytes('0x' + bytesToHex(bytes))).toEqual(bytes)
|
|
129
|
+
expect(hexToBytes('0X' + bytesToHex(bytes))).toEqual(bytes)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws on odd-length hex', () => {
|
|
133
|
+
expect(() => hexToBytes('abc')).toThrow(/odd-length/)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('throws on non-hex characters (no silent 0-coercion)', () => {
|
|
137
|
+
expect(() => hexToBytes('zz')).toThrow(/non-hex/)
|
|
138
|
+
expect(() => hexToBytes('12g4')).toThrow(/non-hex/)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BitcoinAccount,
|
|
3
|
+
BitcoinPsbtSignRequest,
|
|
4
|
+
BitcoinSignMessageParams,
|
|
5
|
+
SignatureType
|
|
6
|
+
} from '@meshconnect/uwc-types'
|
|
7
|
+
import { WalletConnectorError } from '@meshconnect/uwc-types'
|
|
8
|
+
import type { BitcoinTransportAdapter } from '../adapter'
|
|
9
|
+
import {
|
|
10
|
+
base64ToBytes,
|
|
11
|
+
bytesToBase64,
|
|
12
|
+
bytesToHex,
|
|
13
|
+
hexToBytes
|
|
14
|
+
} from '../encoding'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The subset of OKX's injected Bitcoin provider this adapter drives
|
|
18
|
+
* (`window.okxwallet.bitcoin`). OKX does NOT expose Bitcoin through Wallet
|
|
19
|
+
* Standard — only this legacy injected provider — so it needs a dedicated
|
|
20
|
+
* adapter, not `WalletStandardBitcoinAdapter`.
|
|
21
|
+
*
|
|
22
|
+
* Wire conventions the provider follows:
|
|
23
|
+
* - `requestAccounts()` prompts + returns the authorized addresses; the caller
|
|
24
|
+
* pairs `getPublicKey()` for the active account's compressed key (hex).
|
|
25
|
+
* - `signMessage(msg, type)` returns a base64 signature.
|
|
26
|
+
* - `signPsbt(psbtHex, options)` takes AND returns PSBT as HEX — so this adapter
|
|
27
|
+
* converts base64 ↔ hex at the boundary (our domain is base64/BIP-174).
|
|
28
|
+
*/
|
|
29
|
+
export interface OkxBitcoinProvider {
|
|
30
|
+
/** Prompt for connection; returns the authorized addresses. */
|
|
31
|
+
requestAccounts(): Promise<string[]>
|
|
32
|
+
/** Non-prompting: the already-authorized addresses ([] if not connected). */
|
|
33
|
+
getAccounts(): Promise<string[]>
|
|
34
|
+
/** The active account's compressed public key, hex-encoded. */
|
|
35
|
+
getPublicKey(): Promise<string>
|
|
36
|
+
/** Sign a message; returns a base64 signature. */
|
|
37
|
+
signMessage(
|
|
38
|
+
message: string,
|
|
39
|
+
type?: 'ecdsa' | 'bip322-simple'
|
|
40
|
+
): Promise<string>
|
|
41
|
+
/** Sign a PSBT (hex in, signed PSBT hex out). */
|
|
42
|
+
signPsbt(
|
|
43
|
+
psbtHex: string,
|
|
44
|
+
options?: { autoFinalized?: boolean }
|
|
45
|
+
): Promise<string>
|
|
46
|
+
on(event: string, handler: (accounts: string[]) => void): void
|
|
47
|
+
removeListener(event: string, handler: (accounts: string[]) => void): void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* OKX (legacy injected) Bitcoin transport adapter — the non-Wallet-Standard
|
|
52
|
+
* injected path. The injected transport resolves `window.okxwallet.bitcoin` by
|
|
53
|
+
* its `injectedId` and hands it here; this adapter maps our domain shapes onto
|
|
54
|
+
* the provider's methods.
|
|
55
|
+
*
|
|
56
|
+
* `sendTransfer` is intentionally not implemented — that is the WalletConnect
|
|
57
|
+
* high-level path. (OKX has `sendBitcoin`, but broadcast is out of scope here:
|
|
58
|
+
* this stays sign-only, matching `WalletStandardBitcoinAdapter`.)
|
|
59
|
+
*/
|
|
60
|
+
export class OkxBitcoinAdapter implements BitcoinTransportAdapter {
|
|
61
|
+
constructor(private readonly provider: OkxBitcoinProvider) {}
|
|
62
|
+
|
|
63
|
+
async getAccounts(): Promise<BitcoinAccount[]> {
|
|
64
|
+
const addresses = await this.provider.requestAccounts()
|
|
65
|
+
// The provider exposes one active public key; pair it with the first
|
|
66
|
+
// (active) address. Tolerate a missing key rather than failing the connect.
|
|
67
|
+
let publicKey = ''
|
|
68
|
+
try {
|
|
69
|
+
publicKey = await this.provider.getPublicKey()
|
|
70
|
+
} catch {
|
|
71
|
+
publicKey = ''
|
|
72
|
+
}
|
|
73
|
+
return addresses.map((address, index) => ({
|
|
74
|
+
address,
|
|
75
|
+
publicKey: index === 0 ? publicKey : ''
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async signMessage(params: BitcoinSignMessageParams): Promise<SignatureType> {
|
|
80
|
+
// OKX signs with its OWN active account (no address param), so the caller's
|
|
81
|
+
// `params.account` is advisory here. Map our protocol name to the
|
|
82
|
+
// provider's ('bip322' → 'bip322-simple').
|
|
83
|
+
const type = params.protocol === 'bip322' ? 'bip322-simple' : 'ecdsa'
|
|
84
|
+
const signature = await this.provider.signMessage(params.message, type)
|
|
85
|
+
if (!signature) {
|
|
86
|
+
throw new WalletConnectorError({
|
|
87
|
+
type: 'unknown',
|
|
88
|
+
message: 'okx bitcoin: signMessage returned no signature'
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
// OKX returns a base64 signature — matches our contract.
|
|
92
|
+
return { type: 'standard', signature }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async signPsbt(request: BitcoinPsbtSignRequest): Promise<string> {
|
|
96
|
+
// Our domain is base64 (BIP-174); OKX speaks hex. autoFinalized:false keeps
|
|
97
|
+
// this sign-only (the wallet returns the signed-but-unfinalized PSBT).
|
|
98
|
+
const psbtHex = bytesToHex(base64ToBytes(request.psbt))
|
|
99
|
+
const signedHex = await this.provider.signPsbt(psbtHex, {
|
|
100
|
+
autoFinalized: false
|
|
101
|
+
})
|
|
102
|
+
if (!signedHex) {
|
|
103
|
+
throw new WalletConnectorError({
|
|
104
|
+
type: 'unknown',
|
|
105
|
+
message: 'okx bitcoin: signPsbt returned no PSBT'
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return bytesToBase64(hexToBytes(signedHex))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
disconnect(): Promise<void> {
|
|
112
|
+
// OKX's injected provider has no disconnect RPC; clearing session state is
|
|
113
|
+
// the caller's job.
|
|
114
|
+
return Promise.resolve()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { WalletConnectorError } from '@meshconnect/uwc-types'
|
|
3
|
+
import {
|
|
4
|
+
WalletStandardBitcoinAdapter,
|
|
5
|
+
type WalletStandardBitcoinAccount,
|
|
6
|
+
type WalletStandardBitcoinWallet
|
|
7
|
+
} from './wallet-standard'
|
|
8
|
+
import { base64ToBytes, bytesToBase64, utf8ToBytes } from '../encoding'
|
|
9
|
+
|
|
10
|
+
const wsAccount: WalletStandardBitcoinAccount = {
|
|
11
|
+
address: 'bc1qpayment',
|
|
12
|
+
publicKey: new Uint8Array([2, 0xaa, 0xbb, 0xcc]),
|
|
13
|
+
purpose: 'payment'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A fake `bitcoin:*` wallet with all three features wired via vi mocks. */
|
|
17
|
+
const fakeWallet = (
|
|
18
|
+
overrides: Partial<WalletStandardBitcoinWallet['features']> = {}
|
|
19
|
+
): WalletStandardBitcoinWallet => ({
|
|
20
|
+
name: 'Fake BTC Wallet',
|
|
21
|
+
accounts: [],
|
|
22
|
+
features: {
|
|
23
|
+
'bitcoin:connect': {
|
|
24
|
+
connect: vi.fn().mockResolvedValue({ accounts: [wsAccount] })
|
|
25
|
+
},
|
|
26
|
+
'bitcoin:signMessage': {
|
|
27
|
+
signMessage: vi.fn().mockResolvedValue([
|
|
28
|
+
{
|
|
29
|
+
signature: new Uint8Array([1, 2, 3]),
|
|
30
|
+
signedMessage: new Uint8Array()
|
|
31
|
+
}
|
|
32
|
+
])
|
|
33
|
+
},
|
|
34
|
+
'bitcoin:signTransaction': {
|
|
35
|
+
signTransaction: vi
|
|
36
|
+
.fn()
|
|
37
|
+
.mockResolvedValue([{ psbt: new Uint8Array([9, 8, 7]) }])
|
|
38
|
+
},
|
|
39
|
+
...overrides
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('WalletStandardBitcoinAdapter', () => {
|
|
44
|
+
it('getAccounts connects and maps accounts to hex publicKey', async () => {
|
|
45
|
+
const wallet = fakeWallet()
|
|
46
|
+
const adapter = new WalletStandardBitcoinAdapter(wallet)
|
|
47
|
+
|
|
48
|
+
const accounts = await adapter.getAccounts()
|
|
49
|
+
|
|
50
|
+
expect(wallet.features['bitcoin:connect']!.connect).toHaveBeenCalledWith({
|
|
51
|
+
purposes: ['payment']
|
|
52
|
+
})
|
|
53
|
+
expect(accounts).toEqual([
|
|
54
|
+
{ address: 'bc1qpayment', publicKey: '02aabbcc', purpose: 'payment' }
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('signMessage encodes the message and returns a base64 standard signature', async () => {
|
|
59
|
+
const wallet = fakeWallet()
|
|
60
|
+
const adapter = new WalletStandardBitcoinAdapter(wallet)
|
|
61
|
+
|
|
62
|
+
const sig = await adapter.signMessage({
|
|
63
|
+
account: { address: 'bc1qpayment', publicKey: '02aabbcc' },
|
|
64
|
+
message: 'verify me'
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const call = (
|
|
68
|
+
wallet.features['bitcoin:signMessage']!.signMessage as ReturnType<
|
|
69
|
+
typeof vi.fn
|
|
70
|
+
>
|
|
71
|
+
).mock.calls[0][0]
|
|
72
|
+
expect(call.account).toBe(wsAccount)
|
|
73
|
+
expect(call.message).toEqual(utf8ToBytes('verify me'))
|
|
74
|
+
expect(sig).toEqual({
|
|
75
|
+
type: 'standard',
|
|
76
|
+
signature: bytesToBase64(new Uint8Array([1, 2, 3]))
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('signPsbt round-trips base64 <-> bytes and returns the signed PSBT', async () => {
|
|
81
|
+
const wallet = fakeWallet()
|
|
82
|
+
const adapter = new WalletStandardBitcoinAdapter(wallet)
|
|
83
|
+
const psbt = bytesToBase64(new Uint8Array([10, 20, 30]))
|
|
84
|
+
|
|
85
|
+
const signed = await adapter.signPsbt({ from: 'bc1qpayment', psbt })
|
|
86
|
+
|
|
87
|
+
const call = (
|
|
88
|
+
wallet.features['bitcoin:signTransaction']!.signTransaction as ReturnType<
|
|
89
|
+
typeof vi.fn
|
|
90
|
+
>
|
|
91
|
+
).mock.calls[0][0]
|
|
92
|
+
expect(call.account).toBe(wsAccount)
|
|
93
|
+
expect(call.psbt).toEqual(base64ToBytes(psbt))
|
|
94
|
+
expect(signed).toBe(bytesToBase64(new Uint8Array([9, 8, 7])))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('signPsbt maps inputsToSign accounts by address', async () => {
|
|
98
|
+
const wallet = fakeWallet()
|
|
99
|
+
const adapter = new WalletStandardBitcoinAdapter(wallet)
|
|
100
|
+
await adapter.getAccounts()
|
|
101
|
+
|
|
102
|
+
await adapter.signPsbt({
|
|
103
|
+
from: 'bc1qpayment',
|
|
104
|
+
psbt: bytesToBase64(new Uint8Array([1])),
|
|
105
|
+
inputsToSign: [
|
|
106
|
+
{ address: 'bc1qpayment', signingIndexes: [0], sigHash: 'ALL' }
|
|
107
|
+
]
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const call = (
|
|
111
|
+
wallet.features['bitcoin:signTransaction']!.signTransaction as ReturnType<
|
|
112
|
+
typeof vi.fn
|
|
113
|
+
>
|
|
114
|
+
).mock.calls[0][0]
|
|
115
|
+
expect(call.inputsToSign).toEqual([
|
|
116
|
+
{ account: wsAccount, signingIndexes: [0], sigHash: 'ALL' }
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('throws for an address with no connected account', async () => {
|
|
121
|
+
const wallet = fakeWallet()
|
|
122
|
+
const adapter = new WalletStandardBitcoinAdapter(wallet)
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
adapter.signMessage({
|
|
126
|
+
account: { address: 'bc1qunknown', publicKey: '00' },
|
|
127
|
+
message: 'x'
|
|
128
|
+
})
|
|
129
|
+
).rejects.toBeInstanceOf(WalletConnectorError)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws when the wallet lacks the signTransaction feature', async () => {
|
|
133
|
+
const wallet = fakeWallet({ 'bitcoin:signTransaction': undefined })
|
|
134
|
+
const adapter = new WalletStandardBitcoinAdapter(wallet)
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
adapter.signPsbt({
|
|
138
|
+
from: 'bc1qpayment',
|
|
139
|
+
psbt: bytesToBase64(new Uint8Array([1]))
|
|
140
|
+
})
|
|
141
|
+
).rejects.toBeInstanceOf(WalletConnectorError)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('does not implement sendTransfer (Wallet Standard has no high-level transfer)', () => {
|
|
145
|
+
const adapter = new WalletStandardBitcoinAdapter(fakeWallet())
|
|
146
|
+
expect(adapter.sendTransfer).toBeUndefined()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BitcoinAccount,
|
|
3
|
+
BitcoinPsbtSignRequest,
|
|
4
|
+
BitcoinSignMessageParams,
|
|
5
|
+
SignatureType
|
|
6
|
+
} from '@meshconnect/uwc-types'
|
|
7
|
+
import { WalletConnectorError } from '@meshconnect/uwc-types'
|
|
8
|
+
import type { BitcoinTransportAdapter } from '../adapter'
|
|
9
|
+
import {
|
|
10
|
+
base64ToBytes,
|
|
11
|
+
bytesToBase64,
|
|
12
|
+
bytesToHex,
|
|
13
|
+
utf8ToBytes
|
|
14
|
+
} from '../encoding'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The subset of the Wallet Standard `bitcoin:*` interface this adapter drives.
|
|
18
|
+
* Structural (no `@wallet-standard/*` dependency); grounded on the community
|
|
19
|
+
* bitcoin-wallet-standard interface that MetaMask exposes. (OKX signs BTC too,
|
|
20
|
+
* but via a legacy injected provider, not Wallet Standard — it needs a separate
|
|
21
|
+
* adapter, not this one.)
|
|
22
|
+
* @see https://github.com/ExodusMovement/bitcoin-wallet-standard
|
|
23
|
+
*/
|
|
24
|
+
export interface WalletStandardBitcoinAccount {
|
|
25
|
+
address: string
|
|
26
|
+
publicKey: Uint8Array
|
|
27
|
+
purpose?: 'payment' | 'ordinals'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BitcoinConnectFeature {
|
|
31
|
+
connect(input: {
|
|
32
|
+
purposes: ('payment' | 'ordinals')[]
|
|
33
|
+
}): Promise<{ accounts: WalletStandardBitcoinAccount[] }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BitcoinSignMessageFeature {
|
|
37
|
+
signMessage(input: {
|
|
38
|
+
account: WalletStandardBitcoinAccount
|
|
39
|
+
message: Uint8Array
|
|
40
|
+
protocol?: 'ecdsa' | 'bip322'
|
|
41
|
+
}): Promise<{ signature: Uint8Array; signedMessage: Uint8Array }[]>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface BitcoinSignTransactionFeature {
|
|
45
|
+
signTransaction(input: {
|
|
46
|
+
account: WalletStandardBitcoinAccount
|
|
47
|
+
psbt: Uint8Array
|
|
48
|
+
inputsToSign?: {
|
|
49
|
+
account: WalletStandardBitcoinAccount
|
|
50
|
+
signingIndexes: number[]
|
|
51
|
+
sigHash?: string
|
|
52
|
+
}[]
|
|
53
|
+
}): Promise<{ psbt: Uint8Array }[]>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The handle the injected transport passes in (a discovered `bitcoin:*` wallet). */
|
|
57
|
+
export interface WalletStandardBitcoinWallet {
|
|
58
|
+
name: string
|
|
59
|
+
accounts: readonly WalletStandardBitcoinAccount[]
|
|
60
|
+
features: Record<string, unknown> & {
|
|
61
|
+
'bitcoin:connect'?: BitcoinConnectFeature
|
|
62
|
+
'bitcoin:signMessage'?: BitcoinSignMessageFeature
|
|
63
|
+
'bitcoin:signTransaction'?: BitcoinSignTransactionFeature
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wallet Standard (`bitcoin:*`) transport adapter — the injected path for
|
|
69
|
+
* wallets that speak Wallet Standard (verified: MetaMask). The injected
|
|
70
|
+
* transport discovers the wallet and hands it here; this adapter performs the
|
|
71
|
+
* bip122-specific operations against it, converting hex/base64 ↔ `Uint8Array`
|
|
72
|
+
* at the boundary.
|
|
73
|
+
*
|
|
74
|
+
* `sendTransfer` is intentionally not implemented — Wallet Standard has no
|
|
75
|
+
* high-level transfer; that is the WalletConnect adapter's path.
|
|
76
|
+
*/
|
|
77
|
+
export class WalletStandardBitcoinAdapter implements BitcoinTransportAdapter {
|
|
78
|
+
private accounts: readonly WalletStandardBitcoinAccount[] | null = null
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param wallet The discovered `bitcoin:*` wallet.
|
|
82
|
+
* @param initialAccounts Optional already-authorized accounts to seed the
|
|
83
|
+
* cache with. Passed on the SILENT restore path so the first
|
|
84
|
+
* `signMessage`/`signPsbt` doesn't re-invoke `bitcoin:connect` (which some
|
|
85
|
+
* wallets surface as UI) via `requireAccount`→`getAccounts`. Omit on a
|
|
86
|
+
* fresh connect, where `getAccounts()` should call `bitcoin:connect` to
|
|
87
|
+
* authorize.
|
|
88
|
+
*/
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly wallet: WalletStandardBitcoinWallet,
|
|
91
|
+
initialAccounts?: readonly WalletStandardBitcoinAccount[]
|
|
92
|
+
) {
|
|
93
|
+
if (initialAccounts && initialAccounts.length > 0) {
|
|
94
|
+
this.accounts = initialAccounts
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async getAccounts(): Promise<BitcoinAccount[]> {
|
|
99
|
+
const connect = this.wallet.features['bitcoin:connect']
|
|
100
|
+
// The wallet exposes accounts only after `bitcoin:connect`; fall back to any
|
|
101
|
+
// it already lists (a wallet without the connect feature).
|
|
102
|
+
this.accounts = connect
|
|
103
|
+
? (await connect.connect({ purposes: ['payment'] })).accounts
|
|
104
|
+
: this.wallet.accounts
|
|
105
|
+
|
|
106
|
+
return this.accounts.map(account => ({
|
|
107
|
+
address: account.address,
|
|
108
|
+
publicKey: bytesToHex(account.publicKey),
|
|
109
|
+
...(account.purpose ? { purpose: account.purpose } : {})
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async signMessage(params: BitcoinSignMessageParams): Promise<SignatureType> {
|
|
114
|
+
const feature = this.wallet.features['bitcoin:signMessage']
|
|
115
|
+
if (!feature) {
|
|
116
|
+
throw this.unsupported('signMessage')
|
|
117
|
+
}
|
|
118
|
+
const account = await this.requireAccount(params.account.address)
|
|
119
|
+
const [result] = await feature.signMessage({
|
|
120
|
+
account,
|
|
121
|
+
message: utf8ToBytes(params.message),
|
|
122
|
+
...(params.protocol ? { protocol: params.protocol } : {})
|
|
123
|
+
})
|
|
124
|
+
if (!result) {
|
|
125
|
+
throw new WalletConnectorError({
|
|
126
|
+
type: 'unknown',
|
|
127
|
+
message: 'wallet-standard bitcoin: signMessage returned no signature'
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return { type: 'standard', signature: bytesToBase64(result.signature) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async signPsbt(request: BitcoinPsbtSignRequest): Promise<string> {
|
|
134
|
+
const feature = this.wallet.features['bitcoin:signTransaction']
|
|
135
|
+
if (!feature) {
|
|
136
|
+
throw this.unsupported('signPsbt')
|
|
137
|
+
}
|
|
138
|
+
const account = await this.requireAccount(request.from)
|
|
139
|
+
const inputsToSign = request.inputsToSign
|
|
140
|
+
? await Promise.all(
|
|
141
|
+
request.inputsToSign.map(async input => ({
|
|
142
|
+
account: await this.requireAccount(input.address),
|
|
143
|
+
signingIndexes: input.signingIndexes,
|
|
144
|
+
...(input.sigHash ? { sigHash: input.sigHash } : {})
|
|
145
|
+
}))
|
|
146
|
+
)
|
|
147
|
+
: undefined
|
|
148
|
+
const [result] = await feature.signTransaction({
|
|
149
|
+
account,
|
|
150
|
+
psbt: base64ToBytes(request.psbt),
|
|
151
|
+
...(inputsToSign ? { inputsToSign } : {})
|
|
152
|
+
})
|
|
153
|
+
if (!result) {
|
|
154
|
+
throw new WalletConnectorError({
|
|
155
|
+
type: 'unknown',
|
|
156
|
+
message: 'wallet-standard bitcoin: signTransaction returned no PSBT'
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
return bytesToBase64(result.psbt)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
disconnect(): Promise<void> {
|
|
163
|
+
this.accounts = null
|
|
164
|
+
return Promise.resolve()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Look up the wallet-native account for an address, connecting first if needed. */
|
|
168
|
+
private async requireAccount(
|
|
169
|
+
address: string
|
|
170
|
+
): Promise<WalletStandardBitcoinAccount> {
|
|
171
|
+
if (!this.accounts) await this.getAccounts()
|
|
172
|
+
const account =
|
|
173
|
+
this.accounts?.find(a => a.address === address) ??
|
|
174
|
+
// The cached set is from an earlier authorize; the wallet may have since
|
|
175
|
+
// switched accounts (its `accounts` update in place). Re-check the
|
|
176
|
+
// wallet's CURRENT accounts before failing — so signing the live account
|
|
177
|
+
// after a switch resolves without re-invoking `bitcoin:connect`.
|
|
178
|
+
this.wallet.accounts?.find(a => a.address === address)
|
|
179
|
+
if (!account) {
|
|
180
|
+
throw new WalletConnectorError({
|
|
181
|
+
type: 'unknown',
|
|
182
|
+
message: `wallet-standard bitcoin: no connected account for "${address}"`
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
return account
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private unsupported(method: string): WalletConnectorError {
|
|
189
|
+
return new WalletConnectorError({
|
|
190
|
+
type: 'unknown',
|
|
191
|
+
message: `wallet-standard bitcoin: this wallet does not support ${method}`
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|