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
11 changes: 11 additions & 0 deletions .changeset/fix-global-wallet-dynamic-peer-drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@sei-js/sei-global-wallet': patch
---

Document that `@dynamic-labs/ethereum-aa` has to match the `@dynamic-labs/global-wallet-client` version npm resolves, and keep the release checks on that resolved version instead of a constant.

Dynamic declares `@dynamic-labs/ethereum-aa` as an exact peer of its client and pins its internal packages to the client's version, so the two move together on every patch. `@dynamic-labs/global-wallet-client` is a `^4.96.3` dependency here, which means a Dynamic patch inside that range changes the peer version consumers need. Pinning an older `@dynamic-labs/ethereum-aa` than the resolved client does not fail the install: npm cannot place the client's exact peer beside the older root copy, so it nests the client under this package and duplicates the whole Dynamic runtime. The [Optional peer versions](https://github.com/sei-protocol/sei-js/blob/main/packages/sei-global-wallet/README.md#optional-peer-versions) table now states this and shows how to read the version the resolved client asks for.

The consumer verifier resolved `4.96.3` regardless of what the range resolved to, so Dynamic publishing `@dynamic-labs/global-wallet-client@4.96.4` turned the nightly consumer run red on a duplicated Dynamic subtree rather than on any change in this repository. It now resolves the declared range against the registry, pins that client and the peer version it requests in each full consumer, and reports both, so a Dynamic patch is exercised the way an application receives it while a peer pin moving outside this package's published range still fails. A client that npm nests instead of hoisting is now reported as such, rather than as an unresolved dependency.

No published dependency or peer range changes.
10 changes: 6 additions & 4 deletions packages/sei-global-wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,12 @@ Both are `configurable` and `writable`, and neither is installed when the consum

## Optional peer versions

Install only the peers needed by the subpaths your application uses. The declared ranges stay deliberately wide, because an optional peer still fails `npm install` with `ERESOLVE` once your application has the package at a version outside the range. The **verified** column is Dynamic 4.96.3's own peer contract, which is the set this package's release checks run against.
Install only the peers needed by the subpaths your application uses. The declared ranges stay deliberately wide, because an optional peer still fails `npm install` with `ERESOLVE` once your application has the package at a version outside the range. The **verified** column is the version set the release checks install; the Dynamic AA entry tracks the client version resolved from the registry.

| Peer | Declared range | Verified against | Needed by |
| --- | --- | --- | --- |
| `viem` | `^2.7.12` | `2.45.3` | `./zerodev`, Dynamic AA |
| `@dynamic-labs/ethereum-aa` | `^4.15.0` | `4.96.3` | `./zerodev` |
| `@dynamic-labs/ethereum-aa` | `^4.15.0` | `4.96.3+` (exact client match) | `./zerodev` |
| `@zerodev/sdk` | `^5.4.36` | `5.5.7` | `./zerodev` |
| `@solana/web3.js` | `^1.92.1` | `1.98.1` | `./solana` |
| `@solana/wallet-standard-features` | `^1.2.0` | `^1.2.0` | `./solana` |
Expand All @@ -133,7 +133,9 @@ Install only the peers needed by the subpaths your application uses. The declare
| `@wallet-standard/wallet` | `^1.1.0` | `^1.1.0` | `./solana` |

> [!NOTE]
> Dynamic 4.96.3 pins several of these exactly for itself, so a version outside the verified column can still be rejected by Dynamic's own peer contract during install, and is not covered by this package's checks. Prefer the verified versions; the wide ranges exist so that upgrading this package never breaks an install on its own.
> Dynamic pins several of these exactly for itself, so a version outside the verified column can still be rejected by Dynamic's own peer contract during install, and is not covered by this package's checks. Prefer the verified versions; the wide ranges exist so that upgrading this package never breaks an install on its own.
>
> `@dynamic-labs/ethereum-aa` is the strictest of them, because Dynamic pins it to its client's exact version. Since `@dynamic-labs/global-wallet-client` is a range here, pinning an older `@dynamic-labs/ethereum-aa` than the client npm resolves does not fail the install: npm cannot place that exact peer beside your older copy, so it nests a second Dynamic runtime under this package instead. Keep it on the exact version the resolved client asks for; `bun run test:sei-global-wallet-release` prints both versions at startup.
>
> Dynamic also declares an optional `zksync-sso@0.2.0` peer for its zkSync path. This package does not redeclare it, so npm surfaces that requirement from Dynamic directly.

Expand All @@ -144,7 +146,7 @@ Two dependencies exist for transitive resolution rather than for this package's

The root, `./eip6963`, and `./ethereum` entrypoints need no optional peer at all, including for type resolution. The release verifier typechecks them with `skipLibCheck: false` in a consumer that installs nothing but this package, so a published declaration that referenced a type from an uninstalled peer would fail the check.

`@dynamic-labs/global-wallet-client` is a `^4.96.3` dependency rather than an exact pin, so applications inherit Dynamic's transitive fixes without waiting for a release here. This repository's lockfile pins the exact version it tests.
`@dynamic-labs/global-wallet-client` is a `^4.96.3` dependency rather than an exact pin, so applications inherit Dynamic's transitive fixes without waiting for a release here. This repository's lockfile pins the exact version it tests. The consumer checks instead resolve that range against the registry on every run, so a Dynamic patch published inside it is exercised the way an application would actually receive it.

A full Bun lockfile regeneration showed that the previous isolated Dynamic 4.96.1 subtree beneath `@dynamic-labs-wallet/browser-wallet-client@1.0.92` was stale: its `^4.81.0` ranges resolve compatibly without it. The verifier now asserts that no `@dynamic-labs/*` package resolves to more than one version, in npm and Bun graphs and in browser bundles, which stays meaningful across Dynamic upgrades.

Expand Down
134 changes: 97 additions & 37 deletions scripts/check-sei-global-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { collectDynamicLineVersions, findDynamicLineConflicts, formatDynamicLineConflicts } from './dynamic-package-lock.js';
import { highestVersion, normalizeNpmViewVersions, parseNpmViewResult } from './dynamic-package-contract.js';
import { collectDynamicLineVersions, findDynamicLineConflicts, formatDynamicLineConflicts, listDynamicPackageInstallations } from './dynamic-package-lock.js';

interface ProcessResult {
exitCode: number;
Expand Down Expand Up @@ -33,7 +34,6 @@ interface PackageLock {

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageDir = join(root, 'packages/sei-global-wallet');
const temporaryRoot = await mkdtemp(join(tmpdir(), 'sei-global-wallet-release-'));
// Documented in packages/sei-global-wallet/README.md: skips the clean npm
// consumers and the whole Bun path, so it never substitutes for a full run.
const fastCheck = process.env.SEI_GLOBAL_WALLET_FAST_CHECK === '1';
Expand All @@ -45,30 +45,6 @@ const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'ut
};
const dynamicRange = manifest.dependencies['@dynamic-labs/global-wallet-client'];

// The versions the consumers are built against: Dynamic 4.96.3's own peer
// contract. The published peer ranges stay wider than these on purpose, so an
// application that already carries one of these packages keeps resolving.
const testedPeerVersions = {
'@dynamic-labs/ethereum-aa': '4.96.3',
'@solana/wallet-standard-features': '^1.2.0',
'@solana/web3.js': '1.98.1',
'@wallet-standard/base': '^1.0.1',
'@wallet-standard/features': '^1.0.3',
'@wallet-standard/wallet': '^1.1.0',
'@zerodev/sdk': '5.5.7',
viem: '2.45.3',
'zksync-sso': '0.2.0'
} as const;

// Guards the two from drifting: every version the harness installs has to be
// allowed by the range the package publishes.
for (const [name, tested] of Object.entries(testedPeerVersions)) {
const declared = manifest.peerDependencies[name];
if (!declared || tested.startsWith('^')) continue;

assert(Bun.semver.satisfies(tested, declared), `Harness installs ${name}@${tested}, which the published ${name}@${declared} peer range excludes`);
}

// The published manifest carries a range so consumers inherit upstream fixes;
// exactness belongs to the lockfile, not the contract.
const assertSatisfiesDynamicRange = (version: string | undefined, label: string) => {
Expand Down Expand Up @@ -116,6 +92,69 @@ const parseJsonOutput = <T>(output: string): T => {

const writeJson = (path: string, value: unknown) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);

const viewJson = async <T>(specifier: string, field: string): Promise<T | undefined> => {
const command = ['npm', 'view', specifier, field, '--json'];
return parseNpmViewResult<T>(await run(command, root, true), command.join(' '));
};

const viewVersions = async (specifier: string) => normalizeNpmViewVersions(await viewJson<string | string[]>(specifier, 'version'));

/**
* Dynamic pins `@dynamic-labs/ethereum-aa` as an exact peer of the client and
* pins its internal packages to the client's version, so the AA version these
* consumers install has to track whatever `dynamicRange` resolves to now rather
* than a constant here. A stale pin does not fail the install: npm cannot place
* the newer client's exact peer next to an older root copy, so it nests the
* client under this package and duplicates the whole Dynamic runtime instead.
*/
const resolveDynamicContract = async () => {
const clientVersions = await viewVersions(`@dynamic-labs/global-wallet-client@${dynamicRange}`);
assert(clientVersions.length > 0, `No @dynamic-labs/global-wallet-client version satisfies the declared ${dynamicRange}`);
const client = highestVersion(clientVersions);

const peers = await viewJson<Record<string, string>>(`@dynamic-labs/global-wallet-client@${client}`, 'peerDependencies');
const aaRange = peers?.['@dynamic-labs/ethereum-aa'];
assert(aaRange, `@dynamic-labs/global-wallet-client@${client} no longer declares an @dynamic-labs/ethereum-aa peer`);

// Resolved to a concrete version, not passed through as a range, so the lock
// assertions below stay exact even if Dynamic ever loosens the peer.
const aaVersions = await viewVersions(`@dynamic-labs/ethereum-aa@${aaRange}`);
assert(aaVersions.length > 0, `No @dynamic-labs/ethereum-aa version satisfies the ${aaRange} peer of @dynamic-labs/global-wallet-client@${client}`);

return { client, ethereumAa: highestVersion(aaVersions) };
};

// AA follows the resolved Dynamic client's exact peer; the remaining versions
// are the fixed compatibility set these consumers exercise. The published peer
// ranges stay wider on purpose, so an application that already carries one of
// these packages keeps resolving.
const makeTestedPeerVersions = (ethereumAa: string) =>
({
'@dynamic-labs/ethereum-aa': ethereumAa,
'@solana/wallet-standard-features': '^1.2.0',
'@solana/web3.js': '1.98.1',
'@wallet-standard/base': '^1.0.1',
'@wallet-standard/features': '^1.0.3',
'@wallet-standard/wallet': '^1.1.0',
'@zerodev/sdk': '5.5.7',
viem: '2.45.3',
'zksync-sso': '0.2.0'
}) as const;

type TestedPeerVersions = ReturnType<typeof makeTestedPeerVersions>;

const assertTestedPeersFitPublishedRanges = (testedPeerVersions: TestedPeerVersions) => {
// Guards the two from drifting: every version the harness installs has to be
// allowed by the range the package publishes. This is what catches Dynamic
// moving its own peer pin outside the range published here.
for (const [name, tested] of Object.entries(testedPeerVersions)) {
const declared = manifest.peerDependencies[name];
if (!declared || tested.startsWith('^')) continue;

assert(Bun.semver.satisfies(tested, declared), `Harness installs ${name}@${tested}, which the published ${name}@${declared} peer range excludes`);
}
};

const baseSafeOverrides = {
axios: '1.18.0',
uuid: '11.1.1'
Expand Down Expand Up @@ -146,12 +185,13 @@ const walletOnlyManifest = (tarball: string, overrides?: Record<string, unknown>
...(overrides ? { overrides } : {})
});

const fullConsumerManifest = (tarball: string, packageManager: 'bun' | 'npm') => ({
const fullConsumerManifest = (tarball: string, packageManager: 'bun' | 'npm', dynamicClientVersion: string, testedPeerVersions: TestedPeerVersions) => ({
name: 'sei-global-wallet-full-consumer',
private: true,
type: 'module',
dependencies: {
...testedPeerVersions,
'@dynamic-labs/global-wallet-client': dynamicClientVersion,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Adding the client as a direct root dependency of the consumer changes the graph shape the harness is meant to reproduce. A real application installs @sei-js/sei-global-wallet and (for ./zerodev) @dynamic-labs/ethereum-aa; it does not declare @dynamic-labs/global-wallet-client itself — that arrives transitively through this package's ^4.96.3 range.

Two consequences:

  1. Because the client is now a root dep, npm will always hoist it, so the if (!hoisted) nesting diagnostic added at L667–675 is effectively unreachable in the only consumer that calls it. The exact failure mode described in the changeset (npm nesting the client under this package and duplicating the Dynamic runtime) can no longer occur in this fixture.
  2. assert.equal(resolved, expectedClientVersion) at L678 becomes close to tautological — it verifies npm honoured a pin the harness itself wrote, not that the declared range resolves to the preflight version.

Pinning @dynamic-labs/ethereum-aa to the resolved client's exact peer (via makeTestedPeerVersions) is the part that actually fixes the drift. Consider leaving the client to resolve transitively from the range and keeping the equality assertion as the real drift check — that way the nesting diagnostic stays live and the fixture matches how a consumer receives the client.

'@sei-js/sei-global-wallet': `file:${tarball}`,
'ethjs-unit': '0.1.6',
'number-to-bn': '1.7.0'
Expand Down Expand Up @@ -623,17 +663,28 @@ const assertAcceptedBunAudit = (result: ProcessResult) => {
);
};

const assertNpmDynamicGraph = (lock: PackageLock) => {
const resolved = assertSatisfiesDynamicRange(lock.packages['node_modules/@dynamic-labs/global-wallet-client']?.version, 'npm consumer');
assert.equal(lock.packages['node_modules/@dynamic-labs/ethereum-aa']?.version, testedPeerVersions['@dynamic-labs/ethereum-aa']);
const assertNpmDynamicGraph = (lock: PackageLock, expectedClientVersion: string, expectedAaVersion: string) => {
const hoisted = lock.packages['node_modules/@dynamic-labs/global-wallet-client']?.version;
// An install that succeeds without a hoisted client means npm could not place
// the client's exact peers at the root and nested it instead, which duplicates
// the Dynamic runtime rather than failing. Name those locations, because
// "did not resolve" would point at a dependency that is in fact installed.
if (!hoisted) {
const nested = listDynamicPackageInstallations(lock.packages, '@dynamic-labs/global-wallet-client');
assert.deepEqual(nested, [], `npm consumer nested @dynamic-labs/global-wallet-client instead of hoisting it: ${nested.join(', ')}`);
}

const resolved = assertSatisfiesDynamicRange(hoisted, 'npm consumer');
assert.equal(resolved, expectedClientVersion, `npm consumer resolved client ${resolved}, expected the preflight contract ${expectedClientVersion}`);
assert.equal(lock.packages['node_modules/@dynamic-labs/ethereum-aa']?.version, expectedAaVersion);

const conflicts = findDynamicLineConflicts(lock.packages, resolved);
assert.deepEqual(conflicts, [], `npm consumer kept a stale Dynamic ${resolved} subtree: ${formatDynamicLineConflicts(conflicts)}`);

return resolved;
};

const assertBunDynamicGraph = (lockText: string) => {
const assertBunDynamicGraph = (lockText: string, expectedClientVersion: string, expectedAaVersion: string) => {
// Bun records every resolution as a "<name>@<version>" specifier rather than
// as install locations, so group the specifiers instead of lock paths.
const versions = new Map<string, Set<string>>();
Expand All @@ -644,9 +695,10 @@ const assertBunDynamicGraph = (lockText: string) => {
const walletClient = [...(versions.get('@dynamic-labs/global-wallet-client') ?? [])];
assert.equal(walletClient.length, 1, `Bun consumer resolved multiple Dynamic clients: ${walletClient.join(', ')}`);
const resolved = assertSatisfiesDynamicRange(walletClient[0], 'Bun consumer');
assert.equal(resolved, expectedClientVersion, `Bun consumer resolved client ${resolved}, expected the preflight contract ${expectedClientVersion}`);
assert(
[...(versions.get('@dynamic-labs/ethereum-aa') ?? [])].includes(testedPeerVersions['@dynamic-labs/ethereum-aa']),
`Bun consumer did not resolve @dynamic-labs/ethereum-aa@${testedPeerVersions['@dynamic-labs/ethereum-aa']}`
[...(versions.get('@dynamic-labs/ethereum-aa') ?? [])].includes(expectedAaVersion),
`Bun consumer did not resolve @dynamic-labs/ethereum-aa@${expectedAaVersion}`
);

// Same major-line invariant as the npm graph: versions outside the client's
Expand Down Expand Up @@ -699,7 +751,15 @@ const assertBrowserMetafile = async (metafilePath: string, lock: PackageLock) =>
assert(!runtimeImports.some(({ path }) => path === 'node:worker_threads' || path === 'worker_threads'));
};

const temporaryRoot = await mkdtemp(join(tmpdir(), 'sei-global-wallet-release-'));
try {
const dynamicContract = await resolveDynamicContract();
console.log(
`Dynamic ${dynamicRange} resolves to global-wallet-client@${dynamicContract.client}, whose peer contract pins @dynamic-labs/ethereum-aa@${dynamicContract.ethereumAa}.`
);
const testedPeerVersions = makeTestedPeerVersions(dynamicContract.ethereumAa);
assertTestedPeersFitPublishedRanges(testedPeerVersions);

await run(['bun', 'run', '--cwd', packageDir, 'build'], root);
const pack = await run(['npm', 'pack', '--json', '--pack-destination', temporaryRoot], packageDir);
const [packResult] = parseJsonOutput<PackResult[]>(pack.stdout);
Expand Down Expand Up @@ -758,11 +818,11 @@ try {

const npmConsumerDir = join(temporaryRoot, 'npm-full');
await mkdir(npmConsumerDir);
await writeJson(join(npmConsumerDir, 'package.json'), fullConsumerManifest(tarball, 'npm'));
await writeJson(join(npmConsumerDir, 'package.json'), fullConsumerManifest(tarball, 'npm', dynamicContract.client, testedPeerVersions));
await setupConsumerFiles(npmConsumerDir);
await run(['npm', 'install', '--no-audit', '--no-fund'], npmConsumerDir);
const npmLock = JSON.parse(await readFile(join(npmConsumerDir, 'package-lock.json'), 'utf8')) as PackageLock;
assertNpmDynamicGraph(npmLock);
assertNpmDynamicGraph(npmLock, dynamicContract.client, dynamicContract.ethereumAa);
assert.equal(npmLock.packages['node_modules/ethjs-unit/node_modules/bn.js']?.version, '4.12.5');
assert.equal(npmLock.packages['node_modules/number-to-bn/node_modules/bn.js']?.version, '4.12.5');
assertMajor(npmLock.packages['node_modules/bn.js']?.version, '5', 'hoisted bn.js');
Expand Down Expand Up @@ -823,11 +883,11 @@ try {
if (!fastCheck) {
const bunConsumerDir = join(temporaryRoot, 'bun-full');
await mkdir(bunConsumerDir);
await writeJson(join(bunConsumerDir, 'package.json'), fullConsumerManifest(tarball, 'bun'));
await writeJson(join(bunConsumerDir, 'package.json'), fullConsumerManifest(tarball, 'bun', dynamicContract.client, testedPeerVersions));
await setupConsumerFiles(bunConsumerDir);
await run(['bun', 'install'], bunConsumerDir);
const bunLock = await readFile(join(bunConsumerDir, 'bun.lock'), 'utf8');
assertBunDynamicGraph(bunLock);
assertBunDynamicGraph(bunLock, dynamicContract.client, dynamicContract.ethereumAa);
assert.match(bunLock, /"bn\.js": \["bn\.js@5\./);
assert.match(bunLock, /"ethjs-unit\/bn\.js": \["bn\.js@4\./);
assert.match(bunLock, /"number-to-bn\/bn\.js": \["bn\.js@4\./);
Expand Down
Loading
Loading