@meshconnect/uwc-bitcoin 0.2.4 → 0.3.0

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.
@@ -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
+ }
@@ -33,6 +33,28 @@ const ethereumNetwork: Network = {
33
33
  networkType: 'evm'
34
34
  }
35
35
 
36
+ /** Same bip122 namespace as Bitcoin, but a different chain id. */
37
+ const dogeNetwork: Network = {
38
+ internalId: 3,
39
+ id: 'bip122:1a91e3dace36e2be3bf030a65679fe82',
40
+ namespace: 'bip122',
41
+ name: 'Dogecoin',
42
+ logoUrl: '',
43
+ nativeCurrency: { name: 'Dogecoin', symbol: 'DOGE', decimals: 8 },
44
+ networkType: 'dogecoin'
45
+ }
46
+
47
+ /** Same bip122 namespace as Bitcoin, but a different chain id. */
48
+ const litecoinNetwork: Network = {
49
+ internalId: 2,
50
+ id: 'bip122:12a765e31ffd4059bada1e25190f6e98',
51
+ namespace: 'bip122',
52
+ name: 'Litecoin',
53
+ logoUrl: '',
54
+ nativeCurrency: { name: 'Litecoin', symbol: 'LTC', decimals: 8 },
55
+ networkType: 'litecoin'
56
+ }
57
+
36
58
  const account: BitcoinAccount = {
37
59
  address: 'bc1qexampleaddress',
38
60
  publicKey: '02aabbcc',
@@ -41,10 +63,12 @@ const account: BitcoinAccount = {
41
63
 
42
64
  const signature: SignatureType = { type: 'standard', signature: 'deadbeef' }
43
65
 
44
- /** Minimal fake adapter so the namespace's delegation can be asserted. */
66
+ /** Full fake adapter so the namespace's delegation can be asserted. */
45
67
  const fakeAdapter = (): BitcoinTransportAdapter => ({
46
68
  getAccounts: vi.fn().mockResolvedValue([account]),
47
69
  signMessage: vi.fn().mockResolvedValue(signature),
70
+ signPsbt: vi.fn().mockResolvedValue('c2lnbmVk'),
71
+ sendTransfer: vi.fn().mockResolvedValue('txid-abc'),
48
72
  disconnect: vi.fn().mockResolvedValue(undefined)
49
73
  })
50
74
 
@@ -64,6 +88,32 @@ describe('assertBitcoinNetwork', () => {
64
88
  ethereumNetwork.id
65
89
  )
66
90
  })
91
+
92
+ it('throws a distinct error for a bip122 network that is not Bitcoin mainnet (Dogecoin)', () => {
93
+ expect(() => assertBitcoinNetwork(dogeNetwork, 'test')).toThrow(
94
+ WalletConnectorError
95
+ )
96
+ expect(() => assertBitcoinNetwork(dogeNetwork, 'test')).toThrow(
97
+ dogeNetwork.id
98
+ )
99
+ // Distinct from the namespace-mismatch message — callers must be able to
100
+ // tell "wrong namespace" apart from "right namespace, wrong chain".
101
+ expect(() => assertBitcoinNetwork(dogeNetwork, 'test')).toThrow(
102
+ /unsupported bip122 chain/
103
+ )
104
+ expect(() => assertBitcoinNetwork(ethereumNetwork, 'test')).not.toThrow(
105
+ /unsupported bip122 chain/
106
+ )
107
+ })
108
+
109
+ it('throws a distinct error for a bip122 network that is not Bitcoin mainnet (Litecoin)', () => {
110
+ expect(() => assertBitcoinNetwork(litecoinNetwork, 'test')).toThrow(
111
+ WalletConnectorError
112
+ )
113
+ expect(() => assertBitcoinNetwork(litecoinNetwork, 'test')).toThrow(
114
+ /unsupported bip122 chain/
115
+ )
116
+ })
67
117
  })
68
118
 
69
119
  describe('BitcoinNamespace', () => {
@@ -88,6 +138,17 @@ describe('BitcoinNamespace', () => {
88
138
  ).toThrow(WalletConnectorError)
89
139
  })
90
140
 
141
+ it('assertSupportedNetwork rejects other bip122 chains (Dogecoin, Litecoin)', () => {
142
+ expect(() =>
143
+ new BitcoinNamespace(fakeAdapter()).assertSupportedNetwork(dogeNetwork)
144
+ ).toThrow(WalletConnectorError)
145
+ expect(() =>
146
+ new BitcoinNamespace(fakeAdapter()).assertSupportedNetwork(
147
+ litecoinNetwork
148
+ )
149
+ ).toThrow(WalletConnectorError)
150
+ })
151
+
91
152
  it('delegates getAccounts to the adapter', async () => {
92
153
  const adapter = fakeAdapter()
93
154
  const result = await new BitcoinNamespace(adapter).getAccounts()
@@ -116,4 +177,34 @@ describe('BitcoinNamespace', () => {
116
177
  new BitcoinNamespace(noDisconnect).disconnect()
117
178
  ).resolves.toBeUndefined()
118
179
  })
180
+
181
+ it('delegates signPsbt to the adapter', async () => {
182
+ const adapter = fakeAdapter()
183
+ const request = { from: 'bc1qexampleaddress', psbt: 'cHNidA==' }
184
+ const result = await new BitcoinNamespace(adapter).signPsbt(request)
185
+ expect(adapter.signPsbt).toHaveBeenCalledWith(request)
186
+ expect(result).toBe('c2lnbmVk')
187
+ })
188
+
189
+ it('delegates sendTransfer to the adapter', async () => {
190
+ const adapter = fakeAdapter()
191
+ const request = { from: 'bc1qexampleaddress', to: 'bc1qdest', amount: 1000 }
192
+ const result = await new BitcoinNamespace(adapter).sendTransfer(request)
193
+ expect(adapter.sendTransfer).toHaveBeenCalledWith(request)
194
+ expect(result).toBe('txid-abc')
195
+ })
196
+
197
+ it('rejects when the adapter does not support signPsbt / sendTransfer', async () => {
198
+ const signOnly: BitcoinTransportAdapter = {
199
+ getAccounts: vi.fn(),
200
+ signMessage: vi.fn()
201
+ }
202
+ const ns = new BitcoinNamespace(signOnly)
203
+ await expect(
204
+ ns.signPsbt({ from: 'bc1q', psbt: 'x' })
205
+ ).rejects.toBeInstanceOf(WalletConnectorError)
206
+ await expect(
207
+ ns.sendTransfer({ from: 'bc1q', to: 'bc1q2', amount: 1 })
208
+ ).rejects.toBeInstanceOf(WalletConnectorError)
209
+ })
119
210
  })
@@ -1,10 +1,14 @@
1
1
  import type {
2
2
  BitcoinAccount,
3
+ BitcoinPsbtSignRequest,
4
+ BitcoinSendTransferRequest,
3
5
  BitcoinSignMessageParams,
4
6
  Namespace,
5
7
  Network,
6
- SignatureType
8
+ SignatureType,
9
+ TransactionResult
7
10
  } from '@meshconnect/uwc-types'
11
+ import { bitcoinNetwork } from '@meshconnect/uwc-constants'
8
12
  import { WalletConnectorError } from '@meshconnect/uwc-types'
9
13
  import type { BitcoinTransportAdapter } from './adapter'
10
14
 
@@ -12,8 +16,13 @@ import type { BitcoinTransportAdapter } from './adapter'
12
16
  export const BITCOIN_NAMESPACE: Namespace = 'bip122'
13
17
 
14
18
  /**
15
- * Assert `network` is a bip122 network, else throw. Shared by the namespace
16
- * module and its transport adapters so the boundary check lives in one place.
19
+ * Assert `network` is the bip122 network this module actually supports
20
+ * (Bitcoin mainnet), else throw. Other bip122 chains (Dogecoin, Litecoin,
21
+ * etc.) share the namespace but differ in chain id — accepting them here
22
+ * would let a Bitcoin-only wallet silently return a Bitcoin address stamped
23
+ * with the wrong network id, so they must be rejected explicitly rather than
24
+ * only checking the namespace. Shared by the namespace module and its
25
+ * transport adapters so the boundary check lives in one place.
17
26
  */
18
27
  export function assertBitcoinNetwork(network: Network, method: string): void {
19
28
  if (network.namespace !== BITCOIN_NAMESPACE) {
@@ -22,6 +31,12 @@ export function assertBitcoinNetwork(network: Network, method: string): void {
22
31
  message: `${method}: expected a bip122 network but got "${network.id}"`
23
32
  })
24
33
  }
34
+ if (network.id !== bitcoinNetwork.id) {
35
+ throw new WalletConnectorError({
36
+ type: 'unknown',
37
+ message: `${method}: unsupported bip122 chain "${network.id}" — only Bitcoin mainnet ("${bitcoinNetwork.id}") is supported`
38
+ })
39
+ }
25
40
  }
26
41
 
27
42
  /**
@@ -65,8 +80,36 @@ export class BitcoinNamespace {
65
80
  return this.adapter.signMessage(params)
66
81
  }
67
82
 
83
+ /**
84
+ * Sign a PSBT (Path B). Returns the wallet-signed PSBT (base64); finalizing +
85
+ * broadcasting is a separate step. Rejects if the active adapter's wallet
86
+ * does not support PSBT signing.
87
+ */
88
+ async signPsbt(request: BitcoinPsbtSignRequest): Promise<string> {
89
+ if (!this.adapter.signPsbt) throw this.unsupported('signPsbt')
90
+ return this.adapter.signPsbt(request)
91
+ }
92
+
93
+ /**
94
+ * High-level transfer (Path A): the wallet signs and broadcasts, returning a
95
+ * txid. Rejects if the active adapter's transport does not offer it.
96
+ */
97
+ async sendTransfer(
98
+ request: BitcoinSendTransferRequest
99
+ ): Promise<TransactionResult> {
100
+ if (!this.adapter.sendTransfer) throw this.unsupported('sendTransfer')
101
+ return this.adapter.sendTransfer(request)
102
+ }
103
+
68
104
  /** Release any session resources the adapter holds. */
69
105
  async disconnect(): Promise<void> {
70
106
  await this.adapter.disconnect?.()
71
107
  }
108
+
109
+ private unsupported(method: string): WalletConnectorError {
110
+ return new WalletConnectorError({
111
+ type: 'unknown',
112
+ message: `BitcoinNamespace.${method}: the active adapter does not support it`
113
+ })
114
+ }
72
115
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Minimal encoding helpers for the boundary between our transport-agnostic
3
+ * domain shapes (hex/base64 strings, JSON-safe) and the Wallet Standard
4
+ * `bitcoin:*` interface (raw `Uint8Array`). Uses only Web-standard APIs
5
+ * (`TextEncoder`, `atob`/`btoa`) so `uwc-bitcoin` stays dependency-free.
6
+ */
7
+
8
+ export function utf8ToBytes(text: string): Uint8Array {
9
+ return new TextEncoder().encode(text)
10
+ }
11
+
12
+ export function bytesToHex(bytes: Uint8Array): string {
13
+ let hex = ''
14
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
15
+ return hex
16
+ }
17
+
18
+ export function bytesToBase64(bytes: Uint8Array): string {
19
+ let binary = ''
20
+ for (const byte of bytes) binary += String.fromCharCode(byte)
21
+ return btoa(binary)
22
+ }
23
+
24
+ export function base64ToBytes(base64: string): Uint8Array {
25
+ const binary = atob(base64)
26
+ const bytes = new Uint8Array(binary.length)
27
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
28
+ return bytes
29
+ }
30
+
31
+ export function hexToBytes(hex: string): Uint8Array {
32
+ const clean = /^0x/i.test(hex) ? hex.slice(2) : hex
33
+ if (clean.length % 2 !== 0) {
34
+ throw new Error('hexToBytes: odd-length hex string')
35
+ }
36
+ // Validate before parsing: `parseInt` on a non-hex pair yields NaN, which a
37
+ // Uint8Array silently coerces to 0 — so bad input would corrupt data (a PSBT
38
+ // or public key) instead of failing fast. Reject it.
39
+ if (!/^[0-9a-fA-F]*$/.test(clean)) {
40
+ throw new Error('hexToBytes: non-hex characters')
41
+ }
42
+ const bytes = new Uint8Array(clean.length / 2)
43
+ for (let i = 0; i < bytes.length; i++) {
44
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16)
45
+ }
46
+ return bytes
47
+ }
package/src/index.ts CHANGED
@@ -4,3 +4,11 @@ export {
4
4
  BITCOIN_NAMESPACE
5
5
  } from './bitcoin-namespace'
6
6
  export type { BitcoinTransportAdapter } from './adapter'
7
+ export { WalletStandardBitcoinAdapter } from './adapters/wallet-standard'
8
+ export type {
9
+ WalletStandardBitcoinWallet,
10
+ WalletStandardBitcoinAccount
11
+ } from './adapters/wallet-standard'
12
+ export { OkxBitcoinAdapter } from './adapters/okx'
13
+ export type { OkxBitcoinProvider } from './adapters/okx'
14
+ export { bytesToHex, hexToBytes } from './encoding'