Skip to content
Merged
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
16 changes: 9 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ jobs:
node-version: 20
cache: npm
cache-dependency-path: browser/package-lock.json
# npm install, not npm ci: the lockfile carries a file: link to the local SDK
# until the last commit on this branch restores the published pin.
- run: npm install
# npm ci, not npm install: every lockfile here resolves @unicitylabs/sphere-sdk from
# registry.npmjs.org at the pinned version, so the install must be reproducible AND
# must fail when package.json and package-lock.json disagree. `npm install` silently
# re-resolves instead — on an SDK-bump PR that is exactly the regression to catch.
- run: npm ci
- run: npm test
# tsc -b covers src/**, which now includes the test files.
- run: npx tsc -b
Expand All @@ -40,7 +42,7 @@ jobs:
node-version: 20
cache: npm
cache-dependency-path: nodejs/package-lock.json
- run: npm install
- run: npm ci
- run: npm test
- run: npx tsc --noEmit

Expand All @@ -56,7 +58,7 @@ jobs:
node-version: 20
cache: npm
cache-dependency-path: backend-auth/frontend/package-lock.json
- run: npm install
- run: npm ci
- run: npm test
- run: npx tsc -b

Expand All @@ -72,7 +74,7 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: bot/package-lock.json
- run: npm install
- run: npm ci
- run: npm test
- run: npm run typecheck

Expand All @@ -88,6 +90,6 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: backend-auth/backend/package-lock.json
- run: npm install
- run: npm ci
- run: npm test
- run: npm run typecheck
17 changes: 12 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
> **SDK floor:** every package pins `@unicitylabs/sphere-sdk` **0.14.2** exactly. Wallet hosts
> from 0.14.1 enforce an SDK version floor at the Connect handshake (`ConnectHost`'s built-in
> default is `0.14.1-0`, overridable via `ConnectHostConfig.minSdkVersion`): a client on an older
> SDK — or one too old to report a version, i.e. anything before 0.14.1 — is refused with
> `UNSUPPORTED_PROTOCOL_VERSION` (4007) carrying `data.requiredSdk` / `data.actualSdk`. The
> Connect protocol is unchanged at **2.1**.
> SDK is refused with `UNSUPPORTED_PROTOCOL_VERSION` (4007) carrying `data.requiredSdk` /
> `data.actualSdk`. `ConnectClient` has reported its version since **0.10.1**, so `actualSdk` is
> the reported string (`"0.13.1"`) — `null` / `"unknown (not reported)"` only reaches a host from
> 0.9.x or 0.10.0. The Connect protocol is unchanged at **2.1**.

Demonstration project with four runnable examples of working with a Sphere wallet: a **browser dApp** and a **Node.js dApp** (both use the Connect protocol to drive a user's wallet), a **bot** that runs its own wallet (direct SDK, no Connect), and a **backend-auth** flow (a frontend brokers a wallet signature, a backend verifies it and issues a JWT). The Connect module enables dApps to interact with Sphere wallets through a transport-agnostic, permission-based RPC interface.

Expand Down Expand Up @@ -342,8 +343,14 @@ Subscribable events (via `client.on()`), using the **sphere-sdk 0.14 names**:
- `nametag:registered` / `nametag:recovered` — Nametag lifecycle
- `address:activated` — New address tracked

Every pre-0.14 name still fires: the host re-emits each one from the new event through a
compatibility adapter, so no dApp subscription silently went dead. New code uses the names above.
The **16** pre-0.14 names listed in the host's `COMPAT_ATTACHERS` (`connect/host/payments-compat.ts`)
still fire — the host re-emits each from the new event through a compatibility adapter. The other
**26** removed names do NOT, and they fail silently: `Sphere.on()` accepts any string, so the
subscribe succeeds and then never delivers. Whole families went that way — every `invoice:*` and
every `swap:*`, plus `sync:started` / `:error` / `:provider`, `inventory:conflict`,
`send:partial-remainder`, `transfer:invalid`, `walletapi:session`, `payment_request:accepted` /
`:response` / `:settling`. Auditing a pre-0.14 dApp means checking its subscriptions against the
adapter list, not assuming they carried over. New code uses the names above;
`browser/src/components/events/EventLogPanel.tsx` holds the canonical list this repo subscribes to.

## Connect Module Source (in sphere-sdk)
Expand Down
3 changes: 2 additions & 1 deletion backend-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ and issues a session JWT.
> 0.14.1 enforce an SDK version floor at the handshake and refuse older clients
> with `UNSUPPORTED_PROTOCOL_VERSION` (4007) before any approval UI appears —
> so an un-bumped dApp simply stops signing anybody in. `src/errors.ts`
> (`describeVersionFloor`) turns that refusal into copy that names the required
> (`describeHandshakeRefusal`) turns that refusal — the SDK floor, the protocol floor
> or a network mismatch — into copy that names the required
> version. The **backend** is unaffected: it only calls
> `recoverPubkeyFromSignature` / `verifySignedMessage`, which are unchanged.

Expand Down
66 changes: 60 additions & 6 deletions backend-auth/frontend/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ERROR_CODES } from '@unicitylabs/sphere-sdk/connect';
import { describeError, describeVersionFloor, isConnectErrorCode, isWalletLocked } from './errors';
import { describeError, describeHandshakeRefusal, isConnectErrorCode, isWalletLocked } from './errors';

const coded = (code: number, message = 'refused') => Object.assign(new Error(message), { code });
const codedWithData = (code: number, message: string, data: unknown) =>
Expand Down Expand Up @@ -33,7 +33,23 @@ describe('describeError', () => {
);
});

it('names the required SDK version when the wallet enforces its floor', () => {
// The refusal a real pre-flip dApp receives: `ConnectClient` has reported its version
// since sphere-sdk 0.10.1, so the wallet names it. This is the reachable case.
it('names both SDK versions when the wallet enforces its floor', () => {
const text = describeError(
codedWithData(
ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION,
'SDK version 0.13.1 is below the required minimum 0.14.1-0',
{ reason: 'protocol_incompatible', requiredSdk: '0.14.1-0', actualSdk: '0.13.1' },
),
);
expect(text).toContain('0.14.1-0');
expect(text).toContain('0.13.1');
});

// `actualSdk: null` reaches a host only from 0.9.x / 0.10.0, which predate the
// handshake's sdkVersion field. Still handled — just not the common case.
it('says so when the client reported no version at all', () => {
const text = describeError(
codedWithData(
ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION,
Expand All @@ -54,20 +70,20 @@ describe('describeError', () => {
});
});

describe('describeVersionFloor', () => {
describe('describeHandshakeRefusal', () => {
it('is null for any other code', () => {
expect(describeVersionFloor(coded(ERROR_CODES.USER_REJECTED))).toBeNull();
expect(describeHandshakeRefusal(coded(ERROR_CODES.USER_REJECTED))).toBeNull();
});

it('is null when the host sent no versions to name', () => {
expect(
describeVersionFloor(codedWithData(ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, 'nope', { reason: 'x' })),
describeHandshakeRefusal(codedWithData(ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, 'nope', { reason: 'x' })),
).toBeNull();
});

it('names both versions when the host reported them', () => {
expect(
describeVersionFloor(
describeHandshakeRefusal(
codedWithData(ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, 'nope', {
requiredSdk: '0.14.1-0',
actualSdk: '0.13.1',
Expand All @@ -78,6 +94,44 @@ describe('describeVersionFloor', () => {
'Upgrade @unicitylabs/sphere-sdk and rebuild.',
);
});

// A protocol-floor 4007 carries no requiredSdk — a describer written for the SDK branch
// alone returns null here and the UI falls back to a message that names nothing.
it('names both protocol versions on a protocol-floor refusal', () => {
const text = describeHandshakeRefusal(
codedWithData(ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, 'Connect protocol 2.0 is below the required minimum 2.1', {
reason: 'protocol_incompatible',
clientProtocol: '2.0',
requiredProtocol: '2.1',
}),
);
expect(text).toContain('2.0');
expect(text).toContain('2.1');
});

// 4008 is the refusal a dApp that omits `network` actually hits, and its bare message
// ('dApp targets a different network than the wallet') names neither side.
it('names both networks on a mismatch', () => {
const text = describeHandshakeRefusal(
codedWithData(ERROR_CODES.INCOMPATIBLE_NETWORK, 'dApp targets a different network than the wallet', {
walletNetwork: { id: 4, name: 'testnet2' },
clientNetwork: { id: 1, name: 'mainnet' },
}),
);
expect(text).toContain('testnet2');
expect(text).toContain('mainnet');
});

it('tells a dApp that declared no network what to pass', () => {
const text = describeHandshakeRefusal(
codedWithData(ERROR_CODES.INCOMPATIBLE_NETWORK, 'dApp targets a different network than the wallet', {
walletNetwork: { id: 4, name: 'testnet2' },
clientNetwork: null,
}),
);
expect(text).toContain('testnet2');
expect(text).toContain('network');
});
});

describe('isConnectErrorCode', () => {
Expand Down
63 changes: 50 additions & 13 deletions backend-auth/frontend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,68 @@ function text(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}

/** A network as `error.data` carries it: `{ id, name }`, a bare id, or nothing. */
function networkName(value: unknown): string | null {
if (typeof value === 'number') return String(value);
if (typeof value !== 'object' || value === null) return null;
const bag = value as Record<string, unknown>;
return text(bag.name) ?? (typeof bag.id === 'number' ? String(bag.id) : text(bag.id));
}

/**
* A wallet host on sphere-sdk >= 0.14.1 refuses any dApp built on an older SDK with
* UNSUPPORTED_PROTOCOL_VERSION (4007), before any approval UI appears. It publishes the two
* versions it compared in `error.data`, so say which version is needed rather than rendering
* a bare "incompatible". Read defensively — `data` crosses postMessage from a peer on an SDK
* version this app does not control.
* A refused handshake, turned into copy that names what to change.
*
* There are THREE shapes behind the two codes, and handling only one leaves the others
* rendering a bare message that names nothing:
* 4007 + requiredSdk/actualSdk — the SDK floor
* 4007 + requiredProtocol/clientProtocol — the Connect protocol floor
* 4008 + walletNetwork/clientNetwork — a network mismatch, which is what a dApp
* that omits `network` hits on first connect
*
* Read defensively — `data` crosses postMessage from a peer on an SDK version this app
* does not control.
*/
export function describeVersionFloor(err: unknown): string | null {
if (!isConnectErrorCode(err, ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION)) return null;
export function describeHandshakeRefusal(err: unknown): string | null {
const isVersion = isConnectErrorCode(err, ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION);
const isNetwork = isConnectErrorCode(err, ERROR_CODES.INCOMPATIBLE_NETWORK);
if (!isVersion && !isNetwork) return null;
const data = (err as { data?: unknown }).data;
if (typeof data !== 'object' || data === null) return null;
const bag = data as Record<string, unknown>;

if (isNetwork) {
const wallet = networkName(bag.walletNetwork);
if (!wallet) return null;
const client = networkName(bag.clientNetwork);
return client
? `This app targets network ${client}, but the wallet is on ${wallet}.`
: `This app declared no network — the wallet is on ${wallet}. Pass \`network\` to ConnectClient.`;
}

const requiredSdk = text(bag.requiredSdk);
if (!requiredSdk) return null;
const actualSdk = text(bag.actualSdk);
const has = actualSdk ? `is built on sphere-sdk ${actualSdk}` : 'reported no sphere-sdk version';
return `This app ${has} — the wallet requires ${requiredSdk} or newer. Upgrade @unicitylabs/sphere-sdk and rebuild.`;
if (requiredSdk) {
// `actualSdk` is a version STRING for any client on sphere-sdk >= 0.10.1; it is null
// only for 0.9.x / 0.10.0, the releases predating the handshake's sdkVersion field.
const actualSdk = text(bag.actualSdk);
const has = actualSdk ? `is built on sphere-sdk ${actualSdk}` : 'reported no sphere-sdk version';
return `This app ${has} — the wallet requires ${requiredSdk} or newer. Upgrade @unicitylabs/sphere-sdk and rebuild.`;
}

const clientProtocol = text(bag.clientProtocol);
const requiredProtocol = text(bag.requiredProtocol);
if (clientProtocol && requiredProtocol) {
return `This app speaks Connect protocol ${clientProtocol} — the wallet requires ${requiredProtocol} or newer. Upgrade @unicitylabs/sphere-sdk and rebuild.`;
}

return null;
}

export function describeError(err: unknown): string {
if (isConnectErrorCode(err, ERROR_CODES.USER_REJECTED) || isConnectErrorCode(err, ERROR_CODES.INTENT_CANCELLED)) {
return 'You declined the signature request in your wallet.';
}
const versionFloor = describeVersionFloor(err);
if (versionFloor) return versionFloor;
const refusal = describeHandshakeRefusal(err);
if (refusal) return refusal;
if (isWalletLocked(err)) {
return 'Your wallet is locked. Unlock it and press Sign in again — you are still connected, so no re-approval is needed.';
}
Expand Down
53 changes: 53 additions & 0 deletions bot/src/balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { type Asset } from '@unicitylabs/sphere-sdk';
import { formatAssets } from './balance';

const asset = (overrides: Partial<Asset>): Asset =>
({
symbol: 'UCT',
totalAmount: '100000000',
decimals: 8,
tokenCount: 1,
...overrides,
}) as Asset;

describe('formatAssets', () => {
it('renders an empty inventory as a placeholder, not an empty line', () => {
expect(formatAssets([])).toBe('(empty)');
});

it('puts the decimal point back — the value arrives in BASE units', () => {
expect(formatAssets([asset({ totalAmount: '150000000' })])).toBe('1.5 UCT (1 token(s))');
});

it('renders one line per asset', () => {
const out = formatAssets([
asset({ symbol: 'UCT', totalAmount: '100000000', tokenCount: 2 }),
asset({ symbol: 'ALPHA', totalAmount: '250', decimals: 2, tokenCount: 1 }),
]);
expect(out.split('\n ')).toEqual(['1 UCT (2 token(s))', '2.5 ALPHA (1 token(s))']);
});

// `Asset.decimals` is a required `number` in the SDK types, but the value crosses a
// network boundary from wallet-api before anything types it. A malformed one must
// degrade to the raw base units, never throw the bot's print loop down.
it.each([
['undefined decimals', { decimals: undefined as unknown as number }],
['negative decimals', { decimals: -1 }],
['fractional decimals', { decimals: 1.5 }],
['non-numeric amount', { totalAmount: 'not-a-number' }],
])('falls back to base units on %s instead of throwing', (_label, overrides) => {
const out = formatAssets([asset(overrides)]);
expect(out).toContain('(base units)');
expect(out).toContain('UCT');
});

it('keeps rendering the good assets when one is malformed', () => {
const out = formatAssets([
asset({ symbol: 'BAD', decimals: -1 }),
asset({ symbol: 'GOOD', totalAmount: '100000000' }),
]);
expect(out).toContain('BAD');
expect(out).toContain('1 GOOD (1 token(s))');
});
});
37 changes: 37 additions & 0 deletions bot/src/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Balance rendering, extracted from `index.ts` so it can be unit-tested.
*
* `index.ts` calls `main()` at module scope — importing it from a test boots a real
* wallet — so anything worth asserting on has to live outside it. That is why
* `coins.ts`, `sendSafety.ts` and `aggregatorKey.ts` are separate modules, and this
* is the same rule applied to the one piece of money formatting the bot does.
*
* Verified against `@unicitylabs/sphere-sdk` **0.14.2**: `Asset` (types/index.ts)
* carries `symbol: string`, `totalAmount: string` in BASE units, `decimals: number`
* and `tokenCount: number`.
*/
import { type Asset } from '@unicitylabs/sphere-sdk';
import { fromBaseUnits } from './coins';

/**
* `Asset.totalAmount` is in BASE units — `fromBaseUnits` puts the decimal point back.
*
* Falls back to the raw base-unit string when the decimal point cannot be placed.
* `Asset.decimals` is typed as a required `number`, so in a well-behaved SDK this
* never fires — but the value crosses a network boundary from wallet-api before it
* is typed, and printing a balance must never be able to kill the bot.
*/
export function formatAssets(assets: Asset[]): string {
if (assets.length === 0) return '(empty)';
return assets
.map((a) => {
let amount: string;
try {
amount = fromBaseUnits(a.totalAmount, a.decimals);
} catch {
amount = `${a.totalAmount} (base units)`;
}
return `${amount} ${a.symbol} (${a.tokenCount} token(s))`;
})
.join('\n ');
}
Loading
Loading