v1 is a ground-up overhaul of the contract, SDK, and services. It has no backwards-compatibility layer — every change below is a clean cut from the 0.12.x line. If you only ever call connectWallet() and submit through PasskeyServer, the two changes that will touch your code are the signing API (a Signer instance instead of an options object) and the TransactionResult discriminated union.
Important
Contract compatibility. v1 is a new on-chain contract (new WASM hash, renumbered errors, new event schema, timestamp expirations). Wallets deployed from the pre-1.0 contract remain live and keep their addresses (the derivation tuple is unchanged), but they run the legacy code until upgraded in place. A v1 SDK talks to v1 wallets; it still decodes legacy error codes (family SmartWalletLegacy) so failures from a legacy wallet are legible.
- Signing pipeline
- Results & error handling
- Errors
- Configuration
- Signer model & expiration
- Storage adapters
- Indexer & discovery
- Packaging & imports
- Removed exports
- Gap analysis
- Contract-side changes
- Behavior changes
sign / signAuthEntry now take a typed Signer instance instead of a mutually-exclusive { keyId | keypair | policy } options object.
// Before (0.12.x)
await kit.sign(txn, { keyId }); // passkey
await kit.sign(txn, { keypair }); // Ed25519
await kit.sign(txn, { policy }); // policy
await kit.sign(txn, { keyId: "any", expiration }); // any passkey + explicit expiration
// After (v1)
import { PasskeySigner, Ed25519Signer, PolicySigner } from "passkey-kit";
await kit.sign(txn); // connected passkey (default)
await kit.sign(txn, new PasskeySigner(keyId)); // specific passkey
await kit.sign(txn, new Ed25519Signer(keypair)); // Ed25519
await kit.sign(txn, new PolicySigner(policy)); // policy
await kit.sign(txn, new PasskeySigner("any"), { expiration }); // any passkey + expirationNotes:
- The per-call options object is now just
{ expiration?: number }. Per-callrpIdis gone —rpIdmoved to thePasskeyKitconstructor (a single source of truth). Ed25519Signer.fromSecret("S…")builds a signer from a secret key (throwsValidationErroron an invalid key).- Multi-sign by calling
signonce per signer; each merges into the flatSignaturesmap, now sorted in Soroban host order (was alocaleCompareapproximation that could produce a map the host rejected).
sign takes a single AssembledTransaction. The old AssembledTransaction | Tx | string tri-input silently dropped memo/fee/operations on its fallback path. If you hold XDR, rebuild first:
// Before: await kit.sign(xdrString)
// After:
import { AssembledTransaction } from "@stellar/stellar-sdk/contract";
const txn = AssembledTransaction.fromXDR(options, xdrString, spec);
await kit.sign(txn);TransactionResult is now a discriminated union on success. Narrow on result.success before reading .error or .hash.
// Before (0.12.x): an untyped { success, hash, error? } object; error was a string.
const result = await server.send(txn);
if (result.success) {
console.log(result.hash);
} else {
console.error(result.error); // string
}
// After (v1)
const result = await server.send(txn);
if (result.success) {
// TransactionSuccess: { success: true; hash: string; ledger?; transactionId? }
console.log(result.hash);
} else {
// TransactionFailure: { success: false; error: PasskeyKitError; hash? }
console.error(`[${result.error.code}] ${result.error.message}`);
}erroris now a typedPasskeyKitError(aContractErrorwhen an on-chain code was decoded), not a string. Branch onresult.error.code.- Success results have no
errorfield; failure results have an optionalhash. - New type exports:
TransactionSuccess,TransactionFailure,SubmissionMethod.
Which methods return this vs. throw. Only submission methods (server.send, server.getTransaction) return a TransactionResult. Everything else throws a typed PasskeyKitError subclass. A pending (non-terminal) relayer status is surfaced as a failure carrying RELAYER_PENDING — keep polling getTransaction; do not treat it as success.
- All thrown errors are now
PasskeyKitErrorsubclasses with a numericcode:ConfigurationError,WalletNotConnectedError,WalletOwnershipError,WebAuthnError,SigningError,SignerNotFoundError,SimulationError,SubmissionError,ValidationError,IndexerError,RelayerError,ContractError. Codes are grouped by concern (1xxx–9xxx,10000for contract-level). - New decoding API:
decodeContractError(diagnostic),contractErrorFromCode(code),CONTRACT_ERROR_REGISTRY, and typesContractErrorFamily/ContractErrorInfo. - Contract error codes were renumbered to 100–129 (see README → Contract error decoding). The legacy 1–9 codes still decode (family
SmartWalletLegacy), so a failure from a legacy wallet is still legible.
if (!result.success && result.error instanceof ContractError) {
switch (result.error.contractErrorName) {
case "SignerExpired": /* 102 */ break;
case "MissingContext": /* 110 */ break;
}
}PasskeyKit config gains rpId, deploySource, and storage:
// Before
new PasskeyKit({ rpcUrl, networkPassphrase, walletWasmHash, timeoutInSeconds, WebAuthn });
// After
new PasskeyKit({
rpcUrl, networkPassphrase, walletWasmHash,
rpId, // NEW: WebAuthn RP id (was read per sign()/connect() call)
deploySource, // NEW: S… secret for the fee payer (default = canonical deployer)
storage, // NEW: StorageAdapter for passkey records
timeoutInSeconds, WebAuthn,
});PasskeyServer config is now nested (was a flat bag of relayer*/mercury* keys):
// Before
new PasskeyServer({
rpcUrl, relayerUrl, relayerApiKey,
mercuryProjectName, mercuryUrl, mercuryJwt, mercuryKey,
});
// After
new PasskeyServer({
networkPassphrase, // NEW: now required
rpcUrl,
relayer: { baseUrl, apiKey, adminSecret?, timeout? },
mercury: { url? }, // keyless; url defaults to the network's hosted endpoint
});networkPassphrase is required. relayer.baseUrl/apiKey replace relayerUrl/relayerApiKey. The old mercury* keys (mercuryProjectName/mercuryJwt/mercuryKey) are gone — Mercury's hosted passkey-indexer is keyless, so mercury is now just an optional { url? } that defaults to the network's hosted endpoint (omit it entirely to use the default).
- Expiration is a UNIX timestamp in seconds (inclusive), not a ledger sequence number. Update any code that computed
latestLedger + N; usenowSeconds + N. SignerLimits::Some(empty map)now means fail-closed (no permissions). Pre-1.0 an empty map meant unlimited. If you passed an empty map to mean "unlimited", passundefinedinstead.- Deploy permission is decoupled from limits. A limits entry for the wallet's own address no longer grants deploy permission;
CreateContract*contexts require an unlimited (undefined-limits) signer. Granting a signer a limits entry for the wallet's own address grants it the full admin surface (it can add an unlimited signer) — treat that as full control. - New
upgrade(newWasmHash)wrapper (contractupgrade, renamed fromupdate_contract_code) and newgetSigner(signerKey)read (contractget_signer).
The kit no longer expects apps to hand-roll localStorage. Import an adapter from the new passkey-kit/storage subpath and pass it as storage:
import { IndexedDBStorage } from "passkey-kit/storage";
const kit = new PasskeyKit({ /* … */, storage: new IndexedDBStorage() });createWallet then remembers the passkey → wallet record automatically, and connectWallet can resolve a wallet from local storage before falling back to an indexer.
PasskeyServer.getSignersnow returns the richerWalletSigner[](from theSignerIndexerabstraction); the old flatIndexedSignerrow type is removed.getContractIdkeeps its{ keyId | publicKey | policy }signature.- A
SignerIndexerabstraction resolved by the keylessMercuryIndexer— exported from the mainpasskey-kitentry (browser-safe; no token), alongside the browser-safe types +lookupWithRetry. - Live Mercury discovery is on by default via Mercury's hosted, keyless passkey-indexer (both networks, full history, both signer generations).
MercuryConfigcollapsed to an optional{ url? }; the oldprojectName/jwt/apiKeyand the interimzephyrExecuteConfirmedgate are gone. Resolve per network withMercuryIndexer.forNetwork(...).
- The package now ships compiled
dist/(ESM +.d.ts) with anexportsmap. Remove anytranspilePackages: ["passkey-kit", "passkey-factory-sdk", …]/ bundler workaround you added for the old raw-TypeScript shipping. @stellar/stellar-sdkis a peer dependency (>=16.0.0) — install it in your app.- Server-only code moved behind the
passkey-kit/serversubpath. ImportPasskeyServerfrompasskey-kit/server, notpasskey-kit, and never from browser code.
// Before
import { PasskeyKit, PasskeyServer } from "passkey-kit";
// After
import { PasskeyKit } from "passkey-kit";
import { PasskeyServer } from "passkey-kit/server"; // server-onlypasskey-factory-sdk— never a real package; the factory design it referenced was abandoned before v1. Remove it from imports and bundler config.PasskeyServerfrom the package root — moved topasskey-kit/server.- The old indexer row type (
Signer/IndexedSigner) — removed.PasskeyServer.getSignersand theMercuryIndexerreturn the richerWalletSignershape (SignerIndexerabstraction). The nameSignernow refers only to the signing-pipeline interface. StellarIndexerBackend/StellarIndexerConfig/indexerForConfig— removed;MercuryIndexer(keyless, both networks) is the one backend.MercuryIndexermoved frompasskey-kit/serverto the mainpasskey-kitentry.
An explicit accounting of capabilities the pre-1.0 version had that v1 changes or drops — and what to use instead.
| Old capability | v1 status | What to do instead |
|---|---|---|
connectWallet({ walletPublicKey }) — resolve/connect a wallet by an Ed25519 G… key |
Removed. connectWallet is passkey-ownership-based by design: it verifies the connecting keyId is a live secp256r1 signer. |
For reverse lookup by an Ed25519 or policy signer, use server.getContractId({ publicKey }) / { policy }, then operate on that address. There is no "connect as an Ed25519 identity" — sign with an Ed25519Signer against a passkey-connected wallet. |
sign(xdrString | Tx) — sign a raw XDR string or Tx |
Removed (lossy fallback). | AssembledTransaction.fromXDR(...) first, then sign(txn). |
Per-call rpId on sign / connectWallet |
Moved to the constructor. | Set rpId once on new PasskeyKit({ rpId }). |
Live signer discovery via Mercury (getSigners / getContractId) |
Live. Rewired onto Mercury's hosted, keyless passkey-indexer — both networks (incl. testnet), full history, both signer generations. | Enumerate with server.getSigners(contractId) (returns WalletSigner[]) and reverse-lookup with server.getContractId({ keyId | publicKey | policy }), or use MercuryIndexer.forNetwork(...) directly. The deterministic connectWallet() path still covers the common reconnect case with no indexer. |
Legacy ("sw_v1", …) tuple events |
Replaced by typed #[contractevent] events. |
Consume the new signer_added/signer_updated/signer_removed/upgraded schema; Mercury's hosted passkey-indexer already does (and still indexes the legacy tuples for older wallets). |
| Raw-TypeScript package (import internal source files) | Removed — ships compiled dist/. |
Use the public entry points (., ./storage, ./server). |
Net: v1 is a superset of the old contract surface (adds upgrade wrapping + get_signer), and a superset of the old SDK surface except for the three intentional API-shape changes above — and server-side discovery is now backed by Mercury's keyless hosted passkey-indexer on both networks (getSigners returns the richer WalletSigner shape instead of the old IndexedSigner row).
If you build against the contract directly (not just the SDK):
__constructor(signer)is the only init path (theinitflag and un-authed first-add_signerare gone).update_contract_code→upgrade(new_wasm_hash); newget_signer(signer_key) -> Option<SignerVal>view.SignerExpiration(Option<u64>)is a UNIX timestamp;SignerLimits::Some(empty)is fail-closed; errors renumbered 100–129; events are#[contractevent]structs; policies gaininstall/uninstall.
See the CHANGELOG for the full list and contracts/smart-wallet-interface/src/ for the canonical interface.
| Situation | Before | After |
|---|---|---|
| Submission failure | { success: false, error: string } |
{ success: false, error: PasskeyKitError } |
| Any non-submission failure | plain Error / failure object |
typed PasskeyKitError subclass (thrown) |
connectWallet with a keyId not on the wallet |
trusted the derived/looked-up address | throws WalletOwnershipError (verifies the keyId is a live signer) |
Empty SignerLimits map |
unlimited | no permissions (fail-closed) |
| Signer/signature expiration unit | ledger sequence | UNIX timestamp (seconds) |
| WebAuthn challenge | fixed string | random 32 bytes |
Signatures map order |
localeCompare |
Soroban host ScVal order |
| Address auth credentials | V1 | V2 (CAP-0071-02; the wallet address is part of the signed payload). The kit refuses to sign non-address-bound entries. |
updateSecp256r1 |
updateSecp256r1(keyId, publicKey, …) |
updateSecp256r1(keyId, …) — the public key is re-read from the ledger, never caller-supplied |