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: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All notable changes to `passkey-kit` are recorded here. The `0.13.0` entry covers the ground-up **v1 overhaul** of the contract, SDK, bindings, and services; `0.13.1` wires live signer discovery onto Mercury's hosted indexer.

## Unreleased
## 0.19.0 — 2026-09-17

- **Added a safe in-place upgrade target for pre-fix legacy wallets.** New
`contracts-legacy/` workspace builds the last pre-1.0 wallet (`e45c42b9…`)
Expand All @@ -25,6 +25,20 @@ All notable changes to `passkey-kit` are recorded here. The `0.13.0` entry cover
New exports: `LegacyWalletError`, `KNOWN_VULNERABLE_WALLET_WASM_HASHES`,
`LEGACY_WALLET_WASM_HASHES`, `LEGACY_UPGRADE_TARGET_WASM_HASH`,
`LEGACY_WALLET_UPGRADE_GUIDE_URL`.
- **Crafted the legacy upgrade from the wallet's actual state.** New kit
methods for wallets this kit cannot connect to: `inspectLegacyWallet(id)`
reads the code hash and the liveness of the instance, current-code, and
target-code entries and returns a status (`vulnerable` / `legacy` /
`current` / `unknown`), the storage cohort, what is archived, and a plain
recommendation; `buildLegacyUpgradeTx(id)` builds
`update_contract_code(<target>)` (restoring archived entries first when
`restoreSource` is configured); `buildLegacyMigrateTx(id, keys)` builds
`migrate_signers`; `signLegacyUpgradeTx(tx, id, signer?)` signs the
wallet's auth entry with an existing passkey or Ed25519 signer without a
connected wallet, root-pinned like every admin write. Submit through
`PasskeyServer.send`. Also exported: `classifyWasmHash`,
`BARE_LAYOUT_WALLET_WASM_HASHES`, `LegacyWalletInspection`,
`LegacyCodeStatus`.
- **Updated the relayer development image dependency.** The standalone relayer
lock now requires `sharp@0.35.4`, which removes the development-only
`GHSA-rgj7-g3m4-5g8c` alert. This change does not affect the published package
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ Wallets deployed from the following mainnet WASM hashes run code in which `updat

Do not deploy new wallets from these hashes, or from any Makefile or `.env` pin older than commit `da472f9`.

Upgrade affected wallets in place to the legacy-line build `c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c`, built from [`contracts-legacy/`](./contracts-legacy). It reads both storage layouts. The earlier post-fix builds `ecd990f0…` and `e45c42b9…` and the v1 build read only the wrapped layout and brick a bare-layout wallet. See [`docs/legacy-wallet-upgrade.md`](./docs/legacy-wallet-upgrade.md) for the procedure.
Upgrade affected wallets in place to the legacy-line build `c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c`, built from [`contracts-legacy/`](./contracts-legacy) and live on mainnet (upload tx `501bd5d5…`, 2026-09-17). It reads both storage layouts. The earlier post-fix builds `ecd990f0…` and `e45c42b9…` and the v1 build read only the wrapped layout and brick a bare-layout wallet. See [`docs/legacy-wallet-upgrade.md`](./docs/legacy-wallet-upgrade.md) for the procedure.

Move funds out of any affected wallet you do not intend to upgrade.

The current SDK refuses to deploy from these hashes and throws `LegacyWalletError` with the upgrade guidance when `connectWallet` meets a wallet running one of them.
The current SDK refuses to deploy from these hashes, throws `LegacyWalletError` with the upgrade guidance when `connectWallet` meets a wallet running one of them, and builds the upgrade itself through `inspectLegacyWallet` / `buildLegacyUpgradeTx` / `signLegacyUpgradeTx`.

## Cargo advisory status

Expand Down
48 changes: 39 additions & 9 deletions docs/legacy-wallet-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ on every subsequent call. The wallet is bricked and its funds are locked.
| Source | `contracts-legacy/` (soroban-sdk 23.0.2, Rust 1.89, `wasm32v1-none`) |
| Canonical artifact | `contracts-legacy/out/smart_wallet.wasm`, committed. CI asserts its hash and runs the tests against it. Built with Rust 1.89 and stellar CLI 27.1.0 on macOS aarch64. `stellar contract build` remaps source paths, but rustc's wasm codegen differs across host platforms, so a rebuild on another host is functionally equivalent with a different hash. Doc comments on exported functions are embedded in the contract spec, so editing them also changes the hash. |
| Testnet upload | tx `ae5e9439ad044b6c6d3cb491ff6f0e5bd60cfcc1eefbc8a56cc6f1f89b93acaf` |
| Mainnet upload | not yet uploaded |
| Mainnet upload | tx `501bd5d5d06ac131e7da213fb29f59b6338332e1bc8900705017dab396102c72` (2026-09-17) |
| Verify / test | `cd contracts-legacy && make verify` / `make test` |

The tests load the real mainnet `0c0a264d…`, `b62f6221…`, and `c5509dfa…`
Expand Down Expand Up @@ -111,7 +111,7 @@ strict build if its operator chooses to.
### With the stellar CLI

```bash
# once per network: upload the code. As of 2026-09-17 it is on testnet only.
# once per network: upload the code. It is live on testnet and mainnet as of 2026-09-17.
stellar contract upload --wasm contracts-legacy/out/smart_wallet.wasm \
--source <funded-key> --network mainnet

Expand Down Expand Up @@ -141,13 +141,43 @@ connect to these wallets and cannot be used for this step.
## What the current kit does with a legacy wallet

`passkey-kit` `0.19.0` and later cannot operate a pre-1.0 wallet, but it
recognizes one. `connectWallet` throws `LegacyWalletError` (code `2006`)
before birth verification when a candidate's current code is one of the
four vulnerable hashes or a patched legacy build. The error's `vulnerable`
flag, `upgradeTarget`, and `guideUrl` fields carry this document's guidance,
and its message says what to do. The constructor also refuses a
known-vulnerable `walletWasmHash`, so no new wallet can be deployed from one.
Perform the upgrade itself with the 0.10.20–0.12.x kit line.
recognizes one and can craft its upgrade.

- `connectWallet` throws `LegacyWalletError` (code `2006`) before birth
verification when a candidate's current code is one of the four vulnerable
hashes or a patched legacy build. The error's `vulnerable` flag,
`upgradeTarget`, and `guideUrl` fields carry this document's guidance, and
its message says what to do. The constructor also refuses a
known-vulnerable `walletWasmHash`, so no new wallet can be deployed from one.
- `inspectLegacyWallet(contractId)` reads the wallet's code hash and the
liveness of its instance, current-code, and target-code entries, and returns
the status, the storage cohort, which entries are archived, and a
recommendation. Read-only.
- `buildLegacyUpgradeTx(contractId)` builds `update_contract_code(<target>)`
for a wallet on a pre-1.0 build. When `restoreSource` is configured it
restores archived entries first; otherwise it throws `RESTORE_REQUIRED`
with guidance.
- `signLegacyUpgradeTx(tx, contractId, signer?)` signs the wallet's auth
entry with one of its existing signers, without connecting. The default is
a discoverable passkey prompt; pass `new PasskeySigner(keyId)` for a
specific credential or an `Ed25519Signer`.
- `buildLegacyMigrateTx(contractId, signerKeys)` builds `migrate_signers`
for the bare cohort after the upgrade. Get the keys from
`PasskeyServer.getSigners` or `MercuryIndexer.getSigners`.

```ts
const { inspection, tx } = await kit.buildLegacyUpgradeTx(contractId);
if (inspection.upgradeRequired) {
const signed = await kit.signLegacyUpgradeTx(tx, contractId, new PasskeySigner(keyId));
await server.send(signed);
if (inspection.migrateRequired) {
const keys = (await server.getSigners(contractId)).map((s) => s.key);
await server.send(await kit.buildLegacyMigrateTx(contractId, keys));
}
}
```

The 0.10.20–0.12.x kit line still works for the same steps if you prefer it.

## Client compatibility after the upgrade

Expand Down
2 changes: 1 addition & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The versions checked into each `package.json` are authoritative; `npm view` is t
|---|---|
| `passkey-kit-sdk` | `0.9.0` |
| `sac-sdk` | `0.4.4` |
| `passkey-kit` | `0.18.3` (publish-tool guard — see [CHANGELOG](../CHANGELOG.md)) |
| `passkey-kit` | `0.19.0` |

> [!IMPORTANT]
> Publishing is an **outward-facing, user-gated** step. Bump the versions intentionally, and have the person with npm access run the publish commands (they hold the credentials and the OTP device).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "passkey-kit",
"version": "0.18.3",
"version": "0.19.0",
"description": "A helper library for creating and using smart wallet accounts on the Stellar blockchain.",
"author": "Tyler van der Hoeven <tyler@stellar.org>",
"license": "MIT",
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export {
wrapError,
} from "./errors.js";

// Legacy (pre-1.0) wallet upgrade helpers
export {
classifyWasmHash,
BARE_LAYOUT_WALLET_WASM_HASHES,
type LegacyCodeStatus,
type LegacyWalletInspection,
} from "./kit/legacy-ops.js";

// Legacy (pre-1.0) wallet code identities
export {
KNOWN_VULNERABLE_WALLET_WASM_HASHES,
Expand Down
151 changes: 151 additions & 0 deletions src/kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
import {
ConfigurationError,
PasskeyKitError,
ValidationError,
PasskeyKitErrorCode,
WalletNotConnectedError,
WalletOwnershipError,
Expand All @@ -69,6 +70,14 @@ import {
SubmissionManager,
} from "./managers/index.js";
import { resolveDeployer } from "./kit/deploy-ops.js";
import {
buildLegacyMigrateTx,
buildLegacyUpgradeTx,
inspectLegacyWallet,
signLegacyUpgradeTx,
type LegacyWalletInspection,
} from "./kit/legacy-ops.js";
import { restoreFootprint } from "./kit/tx-ops.js";
import {
buildSecp256r1Signer,
type PolicySignerTxOptions,
Expand Down Expand Up @@ -173,6 +182,8 @@ export class PasskeyKit {
readonly acceptedWasmHashes: readonly string[];
/** Accepted immutable birth code identities, lowercase hex. Never empty. */
readonly acceptedBirthWasmHashes: readonly string[];
/** Funded key used only for footprint restores, when configured. */
private readonly restoreKeypair?: Keypair;
/** Full-history source for immutable wallet-birth verification. */
readonly history?: Horizon.Server;
readonly rpId?: string;
Expand Down Expand Up @@ -302,6 +313,8 @@ export class PasskeyKit {
calculateExpiration({ rpc: this.rpc, timeoutInSeconds: this.timeoutInSeconds }),
});

this.restoreKeypair = restoreKeypair;

this.submissionManager = new SubmissionManager({
rpc: this.rpc,
rpcUrl: config.rpcUrl,
Expand Down Expand Up @@ -911,6 +924,144 @@ export class PasskeyKit {
}
}

// -- Legacy (pre-1.0) wallet upgrade ------------------------------------------

/**
* Inspect a wallet this kit cannot connect to and say what it needs:
* its code status (vulnerable / legacy / current / unknown), its storage
* cohort, which ledger entries are archived, and a plain recommendation.
* Read-only.
*/
inspectLegacyWallet(contractId: string): Promise<LegacyWalletInspection> {
return inspectLegacyWallet(
{
rpc: this.rpc,
acceptedWasmHashes: this.acceptedWasmHashes,
contractWasmHash: (id) => this.contractWasmHash(id),
},
contractId
);
}

private legacyTxDeps(contractId: string) {
return {
rpcUrl: this.rpcUrl,
networkPassphrase: this.networkPassphrase,
timeoutInSeconds: this.timeoutInSeconds,
spec: new PasskeyClient({
contractId,
rpcUrl: this.rpcUrl,
networkPassphrase: this.networkPassphrase,
}).spec,
};
}

/**
* Restore archived entries a simulated transaction needs, using the
* configured `restoreSource`, and rebuild. Throws with guidance when no
* restore source is configured.
*/
private async withRestore<T>(
build: () => Promise<AssembledTransaction<T>>,
contractId: string
): Promise<AssembledTransaction<T>> {
let tx = await build();
const simulation = tx.simulation;
if (simulation && Api.isSimulationRestore(simulation)) {
Comment on lines +968 to +970
if (!this.restoreKeypair) {
throw new PasskeyKitError(
`Wallet ${contractId} has archived ledger entries that must be restored before this ` +
`call can run. Configure \`restoreSource\` (a funded key) on the kit, or submit a ` +
`RestoreFootprint operation yourself, then retry.`,
PasskeyKitErrorCode.RESTORE_REQUIRED,
{ context: { contractId } }
);
}
await restoreFootprint(
{
rpc: this.rpc,
networkPassphrase: this.networkPassphrase,
sourceKeypair: this.restoreKeypair,
timeoutInSeconds: this.timeoutInSeconds,
},
simulation.restorePreamble
);
tx = await build();
}
return tx;
}

/**
* Build the in-place upgrade for a pre-1.0 wallet:
* `update_contract_code(<legacy-line target>)`, authorized by the wallet.
* Refuses wallets that are not on a known pre-1.0 build. Restores archived
* entries first when `restoreSource` is configured.
*
* Sign it with {@link signLegacyUpgradeTx} and submit through
* `PasskeyServer.send` (or your own funded source).
*/
async buildLegacyUpgradeTx(
contractId: string
): Promise<{ inspection: LegacyWalletInspection; tx: AssembledTransaction<null> }> {
const inspection = await this.inspectLegacyWallet(contractId);
if (inspection.status !== "vulnerable" && inspection.status !== "legacy") {
throw new ValidationError(
`Wallet ${contractId} is not on a known pre-1.0 build (${inspection.status}); ` +
`there is no legacy upgrade to build. ${inspection.recommendation}`,
PasskeyKitErrorCode.INVALID_INPUT,
{ contractId, wasmHash: inspection.wasmHash, status: inspection.status }
);
}
const tx = await this.withRestore(
() => buildLegacyUpgradeTx(this.legacyTxDeps(contractId), contractId, inspection.upgradeTarget),
contractId
);
return { inspection, tx };
}

/**
* Build `migrate_signers(keys)` for a wallet already upgraded to the
* legacy-line target. Needs no wallet authorization; any funded source can
* submit it. Get the keys from `PasskeyServer.getSigners` /
* `MercuryIndexer.getSigners`.
*/
buildLegacyMigrateTx(
contractId: string,
signerKeys: readonly SignerKey[]
): Promise<AssembledTransaction<number>> {
return this.withRestore(
() => buildLegacyMigrateTx(this.legacyTxDeps(contractId), contractId, signerKeys),
contractId
);
}

/**
* Sign a legacy wallet's upgrade transaction with one of its existing
* signers, without connecting. Defaults to a discoverable passkey prompt;
* pass `new PasskeySigner(keyId)` to require a specific credential, or an
* `Ed25519Signer`.
*/
signLegacyUpgradeTx<T>(
tx: AssembledTransaction<T>,
contractId: string,
signer: Signer = new PasskeySigner("any"),
options?: Omit<SignOptions, "allowWalletReentry">
): Promise<AssembledTransaction<T>> {
return signLegacyUpgradeTx(
{
networkPassphrase: this.networkPassphrase,
spec: this.legacyTxDeps(contractId).spec,
signerContext: this.signerContext(),
calculateExpiration: () =>
calculateExpiration({ rpc: this.rpc, timeoutInSeconds: this.timeoutInSeconds }),
contractId,
},
tx,
signer,
options
);
}

// -- Signing -----------------------------------------------------------------

/** Sign a single auth entry (defaults to the connected passkey signer). */
Expand Down
Loading
Loading