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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

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.

## 0.19.1 — 2026-09-17

- **Pinned what `signLegacyUpgradeTx` will sign.** The auth entry must root at
the named wallet's own top-level `update_contract_code` or `migrate_signers`
with no sub-invocations, and an `update_contract_code` may only carry the
canonical legacy-line target (override with `expectedTarget`). `0.19.0`
pinned the root to the transaction's own call but not the function name or
the target, so a hostile transaction rooted at another wallet-admin call
could have been signed through this path.
- **Refused a pointless re-upgrade.** `buildLegacyUpgradeTx` now rejects a
wallet already on the legacy-line target.
- **Counted an entry as live through its `liveUntil` ledger inclusive** in
`inspectLegacyWallet`.
- **Pointed `LegacyWalletError` at the kit's own helpers**, not only at the
0.10.20–0.12.x line.
- **Docs.** README no longer says this release has no legacy migration path;
the guide's application section, restore step (protocol-23 auto-restore),
policy-signer boundary, migrate-decision timing, and a live testnet
verification of both cohorts through the SDK path.

## 0.19.0 — 2026-09-17

- **Added a safe in-place upgrade target for pre-fix legacy wallets.** New
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ Each method builds an `AssembledTransaction` (`WalletTx`) that wraps one contrac
| `remove(signerKey)` | `remove_signer` | Remove a signer. A policy entry must pass its own `policy__` check. |
| `upgrade(newWasmHash)` | `upgrade` | Replace the wallet's WASM (`Buffer`/`Uint8Array`, 32 bytes). |
| `getSigner(signerKey)` | `get_signer` | Read a signer entry from the ledger (temporary before persistent). Returns `SignerVal \| null`. |
| `inspectLegacyWallet(contractId)` | (reads) | Status, storage cohort, archived entries, and a recommendation for a pre-1.0 wallet this kit cannot connect to. |
| `buildLegacyUpgradeTx(contractId)` | `update_contract_code` | The in-place upgrade to the legacy-line target; restores archived entries first via `restoreSource`. |
| `signLegacyUpgradeTx(tx, contractId, signer?)` | — | Sign that upgrade with an existing passkey or Ed25519 signer without connecting. |
| `buildLegacyMigrateTx(contractId, signerKeys)` | `migrate_signers` | Re-encode pre-`6a27d48` signer entries after the upgrade. |

Parameters:

Expand Down Expand Up @@ -517,7 +521,7 @@ It uses the latest Stellar RPC ledger close timestamp.
- **Current code identity is not signer provenance.** The SDK also verifies immutable birth code and address-bound proofs. See the [signer-provenance design](docs/security-signer-provenance-v2.md).
- **WebAuthn requires User Presence (UP), not User Verification (UV).** The contract requires the UP flag but not UV (biometric/PIN), so it stays compatible with non-UV authenticators. Enforce UV at the client/relayer layer if you need it.
- **Value-moving policies need a cumulative cap or a co-signer.** A `Signature::Policy` carries no secret, so a per-transfer cap alone is trivially drained by repeated capped transfers. See the [contract interface](#contract-interface) and `sample-policy`.
- **Pre-1.1 wallets cannot use this release.** This alpha release has no legacy connection or migration path.
- **Pre-1.1 wallets cannot connect through this release.** `connectWallet` throws `LegacyWalletError` for them. The kit does inspect them and build, sign, and migrate their in-place upgrade (`inspectLegacyWallet`, `buildLegacyUpgradeTx`, `signLegacyUpgradeTx`, `buildLegacyMigrateTx`); see [docs/legacy-wallet-upgrade.md](./docs/legacy-wallet-upgrade.md).

## Contract interface

Expand Down Expand Up @@ -567,6 +571,7 @@ This tuple remains stable for address compatibility. Signer proofs provide owner
| `packages/passkey-kit-sdk` | Generated smart-wallet contract bindings (do not hand-edit — see [releasing](./docs/releasing.md)). |
| `packages/sac-sdk` | Generated SEP-41 SAC bindings. |
| `contracts/` | Rust Soroban contracts: `smart-wallet`, `smart-wallet-interface`, `sample-policy`, `example-contract`. |
| `contracts-legacy/` | The pre-1.0 wallet line with dual-layout signer reads: the in-place upgrade target for pre-fix wallets. The canonical artifact is committed at `out/smart_wallet.wasm`. |
| `relayer-proxy/` | Cloudflare Worker for keyless, fee-sponsored submission. |
| `demo/` | Svelte 5 demo exercising the full client API. |

Expand Down
11 changes: 6 additions & 5 deletions contracts-legacy/smart-wallet/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! Tests for the legacy-line upgrade target.
//!
//! The end-to-end tests load the real mainnet WASM of two vulnerable builds
//! (`fixtures/`), create a wallet on each, upgrade it in place to the WASM
//! built from this crate, and check that the wallet keeps working, that the
//! hole is closed, and that `migrate_signers` re-encodes the pre-`6a27d48`
//! entries. `make test` builds the WASM first.
//! The end-to-end tests load the real mainnet WASM of three vulnerable builds
//! (`fixtures/`), create a wallet on each, upgrade it in place to the committed
//! canonical artifact (`out/smart_wallet.wasm`), and check that the wallet
//! keeps working, that the hole is closed, and that `migrate_signers`
//! re-encodes the pre-`6a27d48` entries. `make test` verifies the artifact's
//! hash first.
extern crate std;

use ed25519_dalek::{Signer as _, SigningKey};
Expand Down
34 changes: 29 additions & 5 deletions docs/legacy-wallet-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ on every subsequent call. The wallet is bricked and its funds are locked.
| Mainnet upload | tx `501bd5d5d06ac131e7da213fb29f59b6338332e1bc8900705017dab396102c72` (2026-09-17) |
| Verify / test | `cd contracts-legacy && make verify` / `make test` |

**Live verification (testnet, 2026-09-17).** The full `passkey-kit` path
(`inspectLegacyWallet` → `buildLegacyUpgradeTx` → `signLegacyUpgradeTx` with an
Ed25519 signer → relayer-style submission of `{ func, auth }` →
`buildLegacyMigrateTx` → `get_signer`) was run against wallets deployed from
the real mainnet bytecode of both cohorts:

| Cohort | Wallet | Upgrade tx | migrate_signers | Result |
|---|---|---|---|---|
| wrapped (`b62f6221…`) | `CBS2KQYXAA44TDANAB6G2DBLCAFPS2IBIMWKGHUJXZH65SEBVWDLLWBQ` | `732e82f4…` | `a1a00b96…` (0 rewritten) | code = target; unauthenticated `update_signer` now fails |
| bare (`0c0a264d…`) | `CA4CYKSPHUYJ7TXFR47D2FHAXJQ6JNNRLUTNF7LQWEYQUOQLZYUPCTOB` | `3a9ba357…` | `3eb34d00…` (1 rewritten, then 0) | code = target; signer readable; unauthenticated `update_signer` now fails |

The tests load the real mainnet `0c0a264d…`, `b62f6221…`, and `c5509dfa…`
WASM, create a wallet on each, upgrade it to this build with a real signed authorization,
and check that the hole is closed, the wallet still authorizes, and
Expand All @@ -91,7 +102,11 @@ funds move. Because the hole is a race, upgrade funded wallets first.
entries, and the code entry of the target hash if needed. Restore is
permissionless and any funded account can pay for it. The legacy SDK line
has no restore helper; build the operation with `@stellar/stellar-sdk`
(`Operation.restoreFootprint` plus a simulated footprint).
(`Operation.restoreFootprint` plus a simulated footprint). On protocol 23
and later, archived entries in a transaction's footprint are restored
automatically as part of that transaction when they fit its limits, so a
separate restore is often unnecessary; the current kit handles the
remaining case through `restoreSource`.
2. **Upgrade.** Invoke `update_contract_code(c079d3a4…)` on the wallet,
authorized by an existing signer. The wallet's current code checks the
authorization, so the signature format is the one that code expects.
Expand Down Expand Up @@ -135,8 +150,9 @@ means the browser. Use the last legacy SDK line (`passkey-kit` `0.10.20`
through `0.12.x`) to connect to the wallet, build a transaction that invokes
`update_contract_code` with the hash above, sign it with the passkey (or
with an Ed25519 signer key the wallet holds), and submit it through your
relayer or a funded source. The current SDK (`0.17.0` and later) does not
connect to these wallets and cannot be used for this step.
relayer or a funded source. Or use the current SDK (`0.19.0` and later): it
does not connect to these wallets, but it builds and signs this exact call;
see the next section.

## What the current kit does with a legacy wallet

Expand All @@ -163,7 +179,13 @@ recognizes one and can craft its upgrade.
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`.
`PasskeyServer.getSigners` or `MercuryIndexer.getSigners`. Decide whether
to migrate from the inspection taken *before* the upgrade: once the wallet
runs the target, `inspectLegacyWallet` no longer knows its original layout.
- `signLegacyUpgradeTx` signs only a top-level `update_contract_code` or
`migrate_signers` on the named wallet, with no sub-invocations, and only
when the upgrade target is the canonical hash (override with
`expectedTarget`). It refuses anything else before hashing.

```ts
const { inspection, tx } = await kit.buildLegacyUpgradeTx(contractId);
Expand Down Expand Up @@ -194,7 +216,9 @@ The 0.10.20–0.12.x kit line still works for the same steps if you prefer it.
- The reverse holds before the upgrade: a bare-layout wallet whose only
usable signer is a policy must authorize the upgrade with a bare-era
signature map (`void` for the policy entry), because the old code has no
`Signature::Policy` variant. Passkey and Ed25519 signatures encode the
`Signature::Policy` variant. The current kit's `PolicySigner` emits
`Signature::Policy`, so it cannot authorize that case; use a passkey or
Ed25519 signer on such a wallet, or a bare-era client. Passkey and Ed25519 signatures encode the
same way in both eras and need no special handling.

## Boundaries
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.19.0` |
| `passkey-kit` | `0.19.1` |

> [!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.19.0",
"version": "0.19.1",
"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
11 changes: 6 additions & 5 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,13 @@ export class LegacyWalletError extends PasskeyKitError {
const message = vulnerable
? `Wallet ${contractId} runs known-vulnerable legacy code ${wasmHash.slice(0, 8)}…: ` +
`its update_signer has no authorization check, so anyone can take the wallet over. ` +
`Upgrade it in place to ${upgradeTarget} with the passkey-kit 0.10.20–0.12.x line ` +
`(update_contract_code, then migrate_signers), or move its funds out. ` +
`This kit version cannot connect to it. Guide: ${guideUrl}`
`Upgrade it in place to ${upgradeTarget}: this kit builds and signs that call ` +
`(kit.buildLegacyUpgradeTx, kit.signLegacyUpgradeTx, then kit.buildLegacyMigrateTx ` +
`for the oldest layout), or use the passkey-kit 0.10.20–0.12.x line; or move its ` +
`funds out. This kit version cannot connect to it. Guide: ${guideUrl}`
: `Wallet ${contractId} runs patched legacy code ${wasmHash.slice(0, 8)}…, ` +
`which this kit version cannot connect to. Use the passkey-kit 0.10.20–0.12.x ` +
`line for this wallet. Guide: ${guideUrl}`;
`which this kit version cannot connect to. Operate it with the passkey-kit ` +
`0.10.20–0.12.x line (kit.inspectLegacyWallet describes its state). Guide: ${guideUrl}`;
super(message, PasskeyKitErrorCode.WALLET_LEGACY_CODE, {
context: { contractId, wasmHash, vulnerable, upgradeTarget, guideUrl },
});
Expand Down
11 changes: 10 additions & 1 deletion src/kit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ describe("legacy wallet code", () => {
expect(legacy.vulnerable).toBe(true);
expect(legacy.wasmHash).toBe(VULNERABLE);
expect(legacy.upgradeTarget).toBe(LEGACY_UPGRADE_TARGET_WASM_HASH);
expect(legacy.message).toContain("update_contract_code");
expect(legacy.message).toContain("buildLegacyUpgradeTx");
expect(legacy.message).toContain(LEGACY_UPGRADE_TARGET_WASM_HASH);
expect(legacy.message).toContain("legacy-wallet-upgrade.md");
expect(legacy.context).toMatchObject({ contractId: birth.contractId, vulnerable: true });
Expand Down Expand Up @@ -764,6 +764,15 @@ describe("legacy wallet code", () => {
expect(error).toBeInstanceOf(LegacyWalletError);
});

it("refuses to build an upgrade for a wallet already on the legacy-line target", async () => {
const kit = makeKit();
vi.spyOn(kit.rpc, "getContractData").mockResolvedValue(
instanceWithWasm(LEGACY_UPGRADE_TARGET_WASM_HASH) as never
);
vi.spyOn(kit.rpc, "getLedgerEntries").mockResolvedValue({ latestLedger: 10, entries: [] } as never);
await expect(kit.buildLegacyUpgradeTx(birth.contractId)).rejects.toThrow(/already runs the legacy-line target/);
});

it("still connects a v1 wallet when a legacy sibling shares the passkey", async () => {
const kit = makeKit();
stubProvenance(kit);
Expand Down
10 changes: 9 additions & 1 deletion src/kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,14 @@ export class PasskeyKit {
contractId: string
): Promise<{ inspection: LegacyWalletInspection; tx: AssembledTransaction<null> }> {
const inspection = await this.inspectLegacyWallet(contractId);
if (inspection.wasmHash === inspection.upgradeTarget) {
throw new ValidationError(
`Wallet ${contractId} already runs the legacy-line target ${inspection.upgradeTarget.slice(0, 8)}…; ` +
`nothing to upgrade. ${inspection.recommendation}`,
PasskeyKitErrorCode.INVALID_INPUT,
{ contractId, wasmHash: inspection.wasmHash, status: inspection.status }
);
}
if (inspection.status !== "vulnerable" && inspection.status !== "legacy") {
throw new ValidationError(
`Wallet ${contractId} is not on a known pre-1.0 build (${inspection.status}); ` +
Expand Down Expand Up @@ -1045,7 +1053,7 @@ export class PasskeyKit {
tx: AssembledTransaction<T>,
contractId: string,
signer: Signer = new PasskeySigner("any"),
options?: Omit<SignOptions, "allowWalletReentry">
options?: Omit<SignOptions, "allowWalletReentry"> & { expectedTarget?: string }
): Promise<AssembledTransaction<T>> {
return signLegacyUpgradeTx(
{
Expand Down
62 changes: 62 additions & 0 deletions src/kit/legacy-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,65 @@ describe("signLegacyUpgradeTx", () => {
}
});
});

describe("signLegacyUpgradeTx target pin", () => {
it("refuses an update_contract_code that carries a different WASM hash", async () => {
const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never);
try {
const tx = await buildLegacyUpgradeTx(
{ rpcUrl: RPC_URL, networkPassphrase: Networks.TESTNET, timeoutInSeconds: 30, spec },
WALLET,
"ee".repeat(32)
);
const error = await signLegacyUpgradeTx(
{
networkPassphrase: Networks.TESTNET,
spec,
signerContext: {} as never,
calculateExpiration: async () => 150,
contractId: WALLET,
},
tx,
new Ed25519Signer(Keypair.random())
).catch((e: unknown) => e);
expect((error as { code?: number }).code).toBe(PasskeyKitErrorCode.SIGNING_FAILED);
expect((error as Error).message).toContain("accepted upgrade target");
} finally {
sim.mockRestore();
}
});
});

describe("signLegacyUpgradeTx function pin", () => {
it("refuses a top-level call that is not update_contract_code or migrate_signers", async () => {
const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never);
try {
// A wallet-admin call built the same way, rooted at this wallet, no subs.
const { AssembledTransaction } = await import("@stellar/stellar-sdk/contract");
const tx = await AssembledTransaction.build<null>({
method: "add_signer",
args: [xdr.ScVal.scvVoid()],
contractId: WALLET,
rpcUrl: RPC_URL,
networkPassphrase: Networks.TESTNET,
timeoutInSeconds: 30,
parseResultXdr: () => null,
});
const error = await signLegacyUpgradeTx(
{
networkPassphrase: Networks.TESTNET,
spec,
signerContext: {} as never,
calculateExpiration: async () => 150,
contractId: WALLET,
},
tx,
new Ed25519Signer(Keypair.random())
).catch((e: unknown) => e);
expect((error as { code?: number }).code).toBe(PasskeyKitErrorCode.SIGNING_FAILED);
expect((error as Error).message).toContain("got add_signer");
} finally {
sim.mockRestore();
}
});
});
Loading
Loading