Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .github/workflows/release-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions SIP030_LISTENERS.md
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] PR-process notes committed as permanent docs

The "Dependency rollout" and "Validation" sections and the "companion to sats-connect-core#131" opener describe this PR rather than the library, and they go stale as soon as Core 0.19.0 ships. The README links to this file as the usage doc. Consider moving those sections to the PR description and keeping only the usage, provider-selection and Gaia-policy content.


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.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
defaultAdapters,
getDefaultProvider,
getSupportedWallets,
listen as listenProvider,
removeDefaultProvider,
setDefaultProvider,
type AddListener,
type Listen,
type Method,
type RequestReturn,
type RpcRequestParams,
Expand Down Expand Up @@ -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.');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Wallet.listen throws on wallets without SIP-030 support, and the documented examples don't handle it

Wallet.listen throws synchronously when no provider is selected, and Core throws when the provider has no native listen. That covers every Xverse version shipped before this rollout, plus the Unisat and Fordefi adapters. The sibling addListener deliberately logs and returns a no-op so that apps don't crash when sats-connect is ahead of the installed wallet (see its comment). The SIP030_LISTENERS.md examples and the README call Wallet.listen with no try/catch, so an app that registers on mount (in a React effect or at page init) will throw for first-time visitors and for users on older wallets. Smallest fix: either match addListener (console.error and return () => {}), or keep the throw, document it, and show try/catch or a capability check in the examples.

}
this.providerId = providerId;

const Adapter = this.defaultAdapters[providerId];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Adapter listen dispatch does the same thing as the Core fallback

The only in-tree adapter that has listen is XverseAdapter, and its implementation is (e, cb) => listen(e, cb, this.id), which is exactly what line 129 already does. Fordefi and Unisat fall through, and defaultAdapters is private with no setter, so consumers can't plug in another implementation. The branch adds an adapter construction per call, and its tests have to mutate the private field to reach it (tests/listeners.test.mjs:146,166). Consider dropping lines 122-125 and calling listenProvider directly. (It does mirror the adapter-dispatch shape of request/addListener, hence a suggestion only.)

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];
Expand Down
Loading
Loading