From 58a935768521a2ee8c7608e116b6fd8fdee58e1c Mon Sep 17 00:00:00 2001 From: Edu Date: Sat, 3 Oct 2026 21:15:12 +0300 Subject: [PATCH] feat: expose SIP-030 listeners through the Sats Connect Wallet API --- .github/workflows/release-pull-request.yml | 3 +- README.md | 4 + SIP030_LISTENERS.md | 68 ++++++ package-lock.json | 8 +- package.json | 4 +- src/index.ts | 19 ++ tests/listeners.test.mjs | 242 +++++++++++++++++++++ tests/listeners.types.ts | 36 +++ tests/tsconfig.json | 8 + 9 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 SIP030_LISTENERS.md create mode 100644 tests/listeners.test.mjs create mode 100644 tests/listeners.types.ts create mode 100644 tests/tsconfig.json diff --git a/.github/workflows/release-pull-request.yml b/.github/workflows/release-pull-request.yml index bf68540..dd663a8 100644 --- a/.github/workflows/release-pull-request.yml +++ b/.github/workflows/release-pull-request.yml @@ -28,7 +28,8 @@ jobs: - run: npm ci # TODO: enable linting once ready # - run: npm run lint - # - run: npm test + - run: npm run check-types + - run: npm run test:listeners publish-beta: needs: diff --git a/README.md b/README.md index b4a90f1..5d68401 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ await request('sendTransfer', {...}); await Wallet.disconnect(); ``` +### SIP-030 network discovery and listeners + +The default `Wallet` object supports `Wallet.request('stx_getNetworks', null)` and synchronous `Wallet.listen(event, callback)` subscriptions for `stx_networkChange` and `stx_accountChange`. The named Core APIs are also re-exported. See [SIP-030 listener usage and rollout](SIP030_LISTENERS.md) for provider selection, cleanup, compatibility and Xverse's deliberate Gaia placeholder policy. + ## 💻 Development ### Build the package ```bash diff --git a/SIP030_LISTENERS.md b/SIP030_LISTENERS.md new file mode 100644 index 0000000..1d563ba --- /dev/null +++ b/SIP030_LISTENERS.md @@ -0,0 +1,68 @@ +# SIP-030 network discovery and listeners + +This companion to `secretkeylabs/sats-connect-core#131` exposes the same native wallet features through the top-level Sats Connect package. Xverse's injected provider implements `listen` directly; applications do not need `@stacks/connect` to access it. Sats Connect and Stacks Connect are alternative client libraries, not prerequisites for the wallet API. + +## Default Wallet API + +After the app has connected/obtained the necessary permissions and selected a wallet: + +```ts +import Wallet from 'sats-connect'; + +const removeNetworkListener = Wallet.listen('stx_networkChange', (network) => { + // network: { active, networks: { id, chainId, transactionVersion }[] } + console.log(network.active); +}); +const removeAccountListener = Wallet.listen('stx_accountChange', (accounts) => { + // A bare Stacks accounts array, not the legacy accountChange envelope. + console.log(accounts.map((account) => account.address)); +}); + +// Subscribe first, then obtain the current network snapshot. +const response = await Wallet.request('stx_getNetworks', null); + +// On component/page teardown: +removeNetworkListener(); +removeAccountListener(); +``` + +`Wallet.listen` is synchronous and returns the provider's unlisten function unchanged. It uses the instance's selected provider, or adopts the saved default if no provider has been selected on that instance. It **never** opens wallet-selection, approval or unlock UI. Select a provider first (for example, through the existing request/selection flow); missing selection or unsupported native listeners fail explicitly. + +Adapters with `listen` are delegated to on a single instance. Unknown/request-only adapters can still use the injected provider's native `listen` through Core. No legacy-event translation, fabricated network/account data or automatic `stx_getAccounts` requests are performed. Existing `Wallet.addListener`, both of its calling conventions, and request/response payloads are unchanged. + +## Named exports and direct providers + +The named Core APIs and types are also re-exported: + +```ts +import { listen, request } from 'sats-connect'; + +const remove = listen( + 'stx_networkChange', + (network) => console.log(network.active), + 'XverseProviders.BitcoinProvider' +); +const response = await request('stx_getNetworks', null, 'XverseProviders.BitcoinProvider'); +remove(); +``` + +Named APIs resolve the supplied provider ID (or their existing default injected provider); they do not use the default `Wallet` object's private selection. `Wallet.listen` should be used when the app wants to honor that selection. Both event names have correctly correlated callback types via Core's `ListenEventMap`. + +Applications can also call an updated wallet's injected `provider.listen` directly, independently of either client library. The availability of these methods still depends on the installed wallet version. + +## Deliberate Xverse Gaia policy + +For the new account event, Xverse supplies real public Stacks addresses/public keys but treats Gaia as deprecated: software and hardware accounts use a 64-character all-zero hexadecimal `gaiaAppKey` and `https://gaia.invalid` as a nonfunctional hub placeholder. These are not storage/authentication credentials. Other wallets may implement a different Gaia policy; Sats Connect forwards their native payloads without replacing fields. + +Xverse suppresses account events while locked without triggering unlock/approval prompts. Connected origins without read permission for the selected account receive `[]`; missing Stacks address/public key also yields `[]`. This does not change existing explicit account requests or legacy events. + +## Dependency rollout + +Temporarily pin the verified published Core prerelease `0.19.0-d1718be`, which includes both SIP event contracts and `stx_getNetworks`. Update the manifest and lockfile to stable Core `0.19.0` once it is available. Do not ship a production release with an accidental older Core dependency or commit local tarball paths. + +The package retains the already-planned, unpublished `sats-connect` version `4.3.0`; this PR does not overwrite a published stable version. + +## Validation + +- `npm run check-types`: source and positive/negative API type fixtures. +- `npm run test:listeners`: build and test the actual public package exports, selected-provider routing, cleanup/receiver forwarding, adapter/native dispatch, unsupported providers, no automatic UI/RPC calls, typed discovery and unchanged legacy listener behavior. diff --git a/package-lock.json b/package-lock.json index e086246..fe1c374 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "4.3.0", "license": "MIT", "dependencies": { - "@sats-connect/core": "0.18.0", + "@sats-connect/core": "0.19.0-d1718be", "@sats-connect/make-default-provider-config": "0.0.10", "@sats-connect/ui": "0.0.7", "valibot": "1.2.0" @@ -1867,9 +1867,9 @@ ] }, "node_modules/@sats-connect/core": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@sats-connect/core/-/core-0.18.0.tgz", - "integrity": "sha512-Oa0bE3Pd0uzj+u8RFtYsvHhYowegXm6qFAbEcpIJybburRkbAcnhbyNJe5dODHdLtHaMx5E4sVwCRP/p1cBojw==", + "version": "0.19.0-d1718be", + "resolved": "https://registry.npmjs.org/@sats-connect/core/-/core-0.19.0-d1718be.tgz", + "integrity": "sha512-DefM7RdoIbObSH9ZlbEIwOsmB+P+OaWM53kn/uCHz5kh+j29/YjOZBUwUARw1ULt86JfV6g1nbW3OMnyGur+yQ==", "license": "ISC", "dependencies": { "axios": "1.13.5", diff --git a/package.json b/package.json index 93f69b3..1dc0ee2 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ ], "scripts": { "test": "jest", + "test:listeners": "npm run build && node --test tests/listeners.test.mjs", + "check-types": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json", "build": "npm run clean && tsup src/index.ts --format esm --dts", "build:watch": "npm run clean && tsup src/index.ts --format esm --dts --watch", "dev:build": "tsup src/index.ts --format esm --dts --watch", @@ -24,7 +26,7 @@ ] }, "dependencies": { - "@sats-connect/core": "0.18.0", + "@sats-connect/core": "0.19.0-d1718be", "@sats-connect/make-default-provider-config": "0.0.10", "@sats-connect/ui": "0.0.7", "valibot": "1.2.0" diff --git a/src/index.ts b/src/index.ts index 6034b8a..0123a4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,9 +7,11 @@ import { defaultAdapters, getDefaultProvider, getSupportedWallets, + listen as listenProvider, removeDefaultProvider, setDefaultProvider, type AddListener, + type Listen, type Method, type RequestReturn, type RpcRequestParams, @@ -110,6 +112,23 @@ class Wallet { return response; } + /** SIP-030 subscriptions never open selection/approval UI or change legacy listeners. */ + public listen: Listen = (event, callback) => { + const providerId = this.providerId ?? getDefaultProvider(); + if (!providerId) { + throw new Error('Select a wallet provider before registering SIP-030 listeners.'); + } + this.providerId = providerId; + + const Adapter = this.defaultAdapters[providerId]; + const adapter = Adapter ? new Adapter() : undefined; + if (adapter?.listen) return adapter.listen(event, callback); + + // Request-only/third-party adapters can still expose a native injected listener. + // Core fails explicitly if it is unavailable; never invoke stx_getAccounts here. + return listenProvider(event, callback, providerId); + }; + public addListener: AddListener = (...rawArgs) => { const listenerInfo: ListenerInfo = (() => { if (rawArgs.length === 1) return rawArgs[0]; diff --git a/tests/listeners.test.mjs b/tests/listeners.test.mjs new file mode 100644 index 0000000..fe88ffc --- /dev/null +++ b/tests/listeners.test.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; +import Wallet, { + listen, + removeDefaultProvider, + request, + setDefaultProvider, +} from '../dist/index.mjs'; + +const globals = ['window', 'document', 'localStorage']; +const originalDescriptors = globals.map((name) => + Object.getOwnPropertyDescriptor(globalThis, name) +); +let uiReads; +beforeEach(() => { + uiReads = 0; + const storage = new Map(); + Object.defineProperty(globalThis, 'window', { configurable: true, value: {}, writable: true }); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: new Proxy( + {}, + { + get() { + uiReads++; + throw new Error('Listener registration must not open selection/approval UI'); + }, + } + ), + }); +}); +afterEach(() => { + globals.forEach((name, index) => { + if (originalDescriptors[index]) + Object.defineProperty(globalThis, name, originalDescriptors[index]); + else delete globalThis[name]; + }); + assert.equal(uiReads, 0); +}); +const newWallet = () => new Wallet.constructor(); +const networkResult = { + active: 'custom', + networks: [{ id: 'custom', chainId: 0, transactionVersion: 128 }], +}; +const accounts = [ + { + address: 'SP123', + publicKey: '02abc', + gaiaHubUrl: 'https://gaia.invalid', + gaiaAppKey: '0'.repeat(64), + }, +]; + +function installProvider(id, provider) { + if (id === 'XverseProviders.BitcoinProvider') + window.XverseProviders = { BitcoinProvider: provider }; + else window[id] = provider; + setDefaultProvider(id); +} + +test('fails synchronously without a selected provider instead of prompting or returning a fake subscription', () => { + window.BitcoinProvider = { + listen() { + assert.fail('An unselected provider must not be used'); + }, + }; + const wallet = newWallet(); + assert.throws(() => wallet.listen('stx_networkChange', () => {}), /Select a wallet provider/); + assert.throws(() => wallet.listen('stx_accountChange', () => {}), /Select a wallet provider/); +}); + +test('forwards both native Xverse events, the callback receiver, and exact cleanup functions', () => { + const callbacks = new Map(); + const cleanups = new Map(); + const provider = { + listen(event, callback) { + assert.equal(this, provider); + callbacks.set(event, callback); + const cleanup = () => callbacks.delete(event); + cleanups.set(event, cleanup); + return cleanup; + }, + }; + installProvider('XverseProviders.BitcoinProvider', provider); + const wallet = newWallet(); + const seen = []; + const removeNetwork = wallet.listen('stx_networkChange', (value) => seen.push(value)); + const removeAccounts = wallet.listen('stx_accountChange', (value) => seen.push(value)); + assert.equal(removeNetwork, cleanups.get('stx_networkChange')); + assert.equal(removeAccounts, cleanups.get('stx_accountChange')); + callbacks.get('stx_networkChange')(networkResult); + callbacks.get('stx_accountChange')(accounts); + assert.deepEqual(seen, [networkResult, accounts]); + removeNetwork(); + removeNetwork(); + assert.equal(callbacks.has('stx_accountChange'), true); + removeAccounts(); + assert.equal(callbacks.size, 0); +}); + +test('uses the selected instance provider instead of switching to a later saved default', () => { + const calls = []; + installProvider('FirstProvider', { + listen(event) { + calls.push(event); + return () => {}; + }, + }); + const wallet = newWallet(); + wallet.listen('stx_networkChange', () => {}); + installProvider('OtherProvider', { + listen() { + assert.fail('Saved defaults must not replace an instance selection'); + }, + }); + wallet.listen('stx_accountChange', () => {}); + removeDefaultProvider(); + wallet.listen('stx_networkChange', () => {}); + assert.deepEqual(calls, ['stx_networkChange', 'stx_accountChange', 'stx_networkChange']); +}); + +test('delegates to an adapter on one instance and retains its native receiver', () => { + let constructed = 0; + const cleanup = () => {}; + const callback = () => {}; + class Adapter { + constructor() { + constructed++; + } + listen(event, cb) { + assert.equal(this instanceof Adapter, true); + assert.equal(event, 'stx_accountChange'); + assert.equal(cb, callback); + return cleanup; + } + } + const wallet = newWallet(); + wallet.defaultAdapters = { AdapterProvider: Adapter }; + setDefaultProvider('AdapterProvider'); + assert.equal(wallet.listen('stx_accountChange', callback), cleanup); + assert.equal(constructed, 1); +}); + +test('request-only and unrecognized adapters can still use a native injected listener', () => { + for (const withAdapter of [true, false]) { + const callback = () => {}; + const cleanup = () => {}; + const provider = { + listen(event, cb) { + assert.equal(this, provider); + assert.equal(event, 'stx_accountChange'); + assert.equal(cb, callback); + return cleanup; + }, + }; + installProvider('CustomProvider', provider); + const wallet = newWallet(); + wallet.defaultAdapters = withAdapter ? { CustomProvider: class {} } : {}; + assert.equal(wallet.listen('stx_accountChange', callback), cleanup); + } +}); + +test('never translates legacy account events or calls an approval RPC when listen is unavailable', () => { + installProvider('XverseProviders.BitcoinProvider', { + request() { + assert.fail('Listen must not request stx_getAccounts or any other RPC'); + }, + addListener() { + assert.fail('Listen must not synthesize SIP results from legacy events'); + }, + }); + const wallet = newWallet(); + assert.throws(() => wallet.listen('stx_accountChange', () => {}), /does not support SIP-030/); + assert.throws(() => wallet.listen('stx_networkChange', () => {}), /does not support SIP-030/); +}); + +test('legacy addListener keeps its object/positional calling conventions and event envelopes', () => { + const registrations = []; + const cleanup = () => {}; + installProvider('XverseProviders.BitcoinProvider', { + addListener(info) { + registrations.push(info); + info.cb({ type: 'accountChange', addresses: [] }); + return cleanup; + }, + }); + const wallet = newWallet(); + const seen = []; + const callback = (event) => seen.push(event); + const info = { eventName: 'accountChange', cb: callback }; + assert.equal(wallet.addListener(info), cleanup); + assert.equal(wallet.addListener('accountChange', callback), cleanup); + assert.equal(registrations[0], info); + assert.deepEqual(registrations[1], info); + assert.deepEqual(seen, [ + { type: 'accountChange', addresses: [] }, + { type: 'accountChange', addresses: [] }, + ]); +}); + +test('named listen is re-exported and supports an explicitly selected provider', () => { + const cleanup = () => {}; + const callback = () => {}; + window.CustomProvider = { + listen(event, cb) { + assert.equal(this, window.CustomProvider); + assert.equal(event, 'stx_accountChange'); + assert.equal(cb, callback); + return cleanup; + }, + }; + assert.equal(listen('stx_accountChange', callback, 'CustomProvider'), cleanup); +}); + +test('re-exported requests expose stx_getNetworks without modifying its result or parameters', async () => { + const calls = []; + window.CustomProvider = { + async request(method, params) { + assert.equal(this, window.CustomProvider); + calls.push([method, params]); + if (method === 'getInfo') + return { jsonrpc: '2.0', id: 'info', error: { code: -32601, message: 'Unsupported' } }; + return { jsonrpc: '2.0', id: 'networks', result: networkResult }; + }, + }; + assert.deepEqual(await request('stx_getNetworks', null, 'CustomProvider'), { + status: 'success', + result: networkResult, + }); + assert.deepEqual(calls, [ + ['getInfo', null], + ['stx_getNetworks', null], + ]); +}); diff --git a/tests/listeners.types.ts b/tests/listeners.types.ts new file mode 100644 index 0000000..412854e --- /dev/null +++ b/tests/listeners.types.ts @@ -0,0 +1,36 @@ +import Wallet, { + listen, + type StacksAccountChangeResult, + type StacksGetNetworksResult, +} from '../src'; + +// Compile-only fixtures: named and default APIs keep event/callback types correlated. +export async function checkListenerTypes() { + const removeNetwork: () => void = Wallet.listen('stx_networkChange', (result) => { + const network: StacksGetNetworksResult = result; + return network; + }); + const removeAccounts: () => void = Wallet.listen('stx_accountChange', (result) => { + const accounts: StacksAccountChangeResult = result; + return accounts; + }); + listen('stx_accountChange', (accounts) => accounts.map((account) => account.address)); + listen('stx_networkChange', (network) => network.active, 'XverseProviders.BitcoinProvider'); + + // @ts-expect-error Account events cannot use a network callback. + Wallet.listen('stx_accountChange', (_result: StacksGetNetworksResult) => {}); + // @ts-expect-error Network events cannot use an account callback. + Wallet.listen('stx_networkChange', (_result: StacksAccountChangeResult) => {}); + // @ts-expect-error Unsupported SIP event names are rejected at compile time. + Wallet.listen('accountChange', () => {}); + // @ts-expect-error Named exports retain the same event/callback correlation. + listen('stx_accountChange', (_result: StacksGetNetworksResult) => {}); + + const response = await Wallet.request('stx_getNetworks', null); + if (response.status === 'success') { + const active: string = response.result.active; + const chainId: number = response.result.networks[0].chainId; + const transactionVersion: number = response.result.networks[0].transactionVersion; + return { active, chainId, transactionVersion, removeNetwork, removeAccounts }; + } +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..becb2be --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./listeners.types.ts"], + "exclude": [] +}