Accounts, recovery & adapters
Connect and recover a smart account, sign and submit passkey smart-wallet transactions, add or remove signers for multi-device recovery, and swap the pluggable submission and indexer adapters.
Everything after create + sign: bringing a returning user back (connect / recover), native multi-device recovery on the v0.2 account (add_signer / remove_signer on the contract itself), the passkey-kit v1 smart-wallet (payments, signer management), and the two pluggable seams for submission and indexing. For the registration and signing primitives, see the SDK core.
connect
Silent reconnect using the stored credential id. Where conditional mediation is available, connect attempts a mediation:'silent' liveness check; it resolves the C-address via the IndexerAdapter regardless, so connect works even where silent mediation is unsupported. Returns null when there is no stored credential (recover from there) or no account.
import { connect } from '@soropass/core/connect';
connect(options: ConnectOptions): Promise<ConnectResult | null>ConnectOptions
| Field | Type | Description |
|---|---|---|
rpId (required) | string | Relying Party ID for the WebAuthn ceremony. |
indexer (required) | IndexerAdapter | Resolves the credential to its C-address(es). |
webauthn | WebAuthnClient | Optional WebAuthn client override (defaults to the platform navigator.credentials). |
storage | CredentialStorage | Where the stored credential id is read from. |
publicKey | Uint8Array | The passkey's 65-byte SEC-1 key. deploy is permissionless, so a credential id can resolve to more than one account; set this to select the one whose factory deployed event names this exact key. |
silentMediationSupported | boolean | Override the isConditionalMediationAvailable probe. |
ConnectResult
Promise<ConnectResult | null>: null means no stored credential or no account; fall through to recover().
| Field | Type | Description |
|---|---|---|
contractId | string | The resolved smart-account C-address. |
credentialId | string | The passkey credential id that controls it. |
recover
The lost-localStorage / new-device path. Performs a discoverable-credential get() (no allowCredentials), then resolves every smart account controlled by that credential via the IndexerAdapter.
import { recover } from '@soropass/core/recover';
recover(options: RecoverOptions): Promise<RecoverResult[]>RecoverOptions
| Field | Type | Description |
|---|---|---|
rpId (required) | string | Relying Party ID for the WebAuthn ceremony. |
indexer (required) | IndexerAdapter | Resolves the recovered credential to its C-address(es). |
webauthn | WebAuthnClient | Optional WebAuthn client override (defaults to the platform navigator.credentials). |
challenge | Uint8Array | Optional challenge bytes for the get() ceremony. |
userActivation | { isActive: boolean } | Caller-supplied user-gesture state for the WebAuthn call. |
RecoverResult
Promise<RecoverResult[]>: one credential can control several accounts; present a picker when the array has more than one entry.
| Field | Type | Description |
|---|---|---|
contractId | string | A smart-account C-address controlled by the credential. |
credentialId | string | The discoverable passkey credential id that was used. |
Connect-then-recover
connect() is the happy path on a known device; a null result falls through to recover(), the new-device / cleared-storage path. The idiomatic startup sequence:
import { connect } from '@soropass/core/connect';
import { recover } from '@soropass/core/recover';
async function restore(rpId, indexer, storage) {
const session = await connect({ rpId, indexer, storage });
if (session) return [session];
// null -> no stored credential / no account: recover
return recover({ rpId, indexer, userActivation: { isActive: true } });
}Both functions map a passkey credential to its C-address(es) through an IndexerAdapter. See Indexer adapters for the bundled events indexer and how to write your own.
Native multi-device recovery
The v0.2 webauthn-account is multi-signer: it enrolls up to 20 passkey signers and exposes add_signer(public_key), remove_signer(public_key), is_signer(public_key), and signer_count() on the account contract itself. Each add or remove is authorized by the account's own __check_auth, so an existing enrolled device signs the change, and the contract refuses to remove the last signer. A lost device never locks the account out: enroll the new device while any enrolled device can still sign, then retire the lost one with remove_signer.
The flow is a standard Soroban invoke signed with the default single-signer target: register a passkey on the new device (no deploy), build the add_signer call, and sign the account's auth entry with an existing device.
import { registerPasskey } from '@soropass/core/create';
import { signTransaction, browserPasskeySigner } from '@soropass/core/sign';
import { Contract, nativeToScVal } from '@stellar/stellar-sdk';
// New device: register + extract, no deploy.
const device2 = await registerPasskey({ rpId, rpName: 'My Wallet', userName: 'alice' });
// The account-authorized add_signer(public_key) invocation:
const operation = new Contract(account.contractId).call(
'add_signer',
nativeToScVal(device2.publicKey, { type: 'bytes' }),
);
// Build a transaction around `operation` with a funded classic source, simulate
// and assemble it, then sign the account's auth entry with the EXISTING device:
const signedXdr = await signTransaction(assembledTxXdr, {
networkPassphrase,
sign: browserPasskeySigner({
rpId,
allowCredentials: [account.credentialId],
publicKey: account.publicKey,
}),
});remove_signer(public_key) retires a device the same way, authorized by any remaining enrolled device. The full sequence (add device B by A, sign by B, remove A by B, A rejected) is proven on testnet: the transaction hashes live in contracts/deployments.json under testnetV02.recoveryProof, and packages/core/scripts/recovery-e2e.ts reproduces it end to end.
The addSigner / removeSigner helpers below target the passkey-kit v1 smart-wallet ABI, not this account.
The v1 smart-wallet
Beyond the native account, @soropass/core also targets the passkey-kit v1 smart-wallet (Protocol 27), a multi-signer account whose type Signature = Signatures(Map<SignerKey, Signature>). The sendSmartWalletTx path drives payments, arbitrary contract calls, and v1 signer management (addSigner / removeSigner).
The passkey-kit author (kalepail, issue #32) confirmed the smart-wallet wire shape:
SignerKey::Secp256r1(raw credential id), the SEC-1 public key in SignerVal, and the canonical
ScVal byte-order map sort. It is proven on testnet (see contracts/deployments.json).
The smart-wallet target
signTransaction / signAuthEntry take a target. The default single-signer assembles the bare Secp256r1Signature the SoroPass account reads; smart-wallet wraps it as the Signatures(Map<SignerKey, Signature>) the passkey-kit wallet reads. The low-S / field-packing / challenge-binding core is the same; only the outer wrapper differs.
import { signTransaction } from '@soropass/core/sign';
const signedXdr = await signTransaction(txXdr, {
networkPassphrase,
sign,
target: 'smart-wallet', // Signatures(Map<SignerKey, Signature>)
});The map is sorted in the Soroban host's canonical order (an element-wise ScVal byte comparison,
not a string/localeCompare sort), so multi-signer entries validate. Existing entries in a
partially-signed auth are preserved and merged.
Smart-wallet primitives
The building blocks behind the smart-wallet target and the addSigner / removeSigner flows, exported from @soropass/core/sign for advanced use (custom multi-signer assembly, or driving the wallet contract directly).
| Function | Returns | Description |
|---|---|---|
applyAssertionToSmartWalletEntry(entry, assertion) | SorobanAuthorizationEntry | Returns a new entry with the assertion set in (or merged into) the Signatures(Map<SignerKey, Signature>) value, keyed by the assertion's credential id and sorted canonically; the input entry is not modified. |
buildSignerKeyScVal(credentialId) | xdr.ScVal | SignerKey::Secp256r1(raw credential id bytes) as an ScVal. |
buildSmartWalletSignatureVariant(assertion) | xdr.ScVal | The Signature::Secp256r1 { authenticator_data, client_data_json, signature } variant for one assertion. |
compareSignerKeyScVal(a, b) | number | The host's canonical element-wise ScVal byte comparator; sort map keys with it. |
buildSecp256r1Signer(spec) | xdr.ScVal | Encodes Signer::Secp256r1 from { credentialId, publicKey, expiration?, storage? }; validates the 65-byte SEC-1 key. |
buildAddSignerOperation(options) | xdr.Operation | The wallet-authorized add_signer(Signer) invocation for { walletContractId, signer }. |
buildRemoveSignerOperation(options) | xdr.Operation | The wallet-authorized remove_signer invocation for { walletContractId, credentialId }. |
referenceSmartWalletCheckAuth verifies the assembled multi-signer entry the way the v1 __check_auth does.
sendSmartWalletTx
Sign and submit a single passkey-authorized smart-wallet transaction, the general primitive behind every smart-wallet action (payment, contract call, add/remove signer). It runs the full submission sequence so you do not have to:
- recording simulation → assemble (discovers the wallet's auth requirement);
- sign the wallet auth entry with the passkey (binding the expiration ledger);
- enforcing re-simulation of the signed tx: recording auth never runs the account's
__check_auth, so it under-counts the footprint and instructions; the enforcing pass yields the true resources and doubles as a client-side proof the auth is accepted; - pay the fee from the classic source and submit via the pluggable adapter.
sendSmartWalletTx(options: SendSmartWalletTxOptions): Promise<SubmitResult>SendSmartWalletTxOptions
| Field | Type | Description |
|---|---|---|
operation (required) | xdr.Operation | The wallet-authorized invocation (a SAC transfer, a contract call, …). |
networkPassphrase (required) | string | Bound into the auth challenge. |
rpcUrl (required) | string | soroban-rpc endpoint used for simulate/assemble. |
sourceSecret (required) | string | Funded classic account that pays the fee + sequences the tx (sponsor only). |
sign (required) | WebAuthnSigner | The wallet's passkey signer that authorizes the invocation. |
submission | SubmissionAdapter | Where the signed tx is sent. Defaults to direct (soroban-rpc). |
verify | SignVerifyOptions | Opt-in pre-flight validation of the assertion. |
signatureExpirationLedgerOffset | number | Ledgers ahead of latest to expire the auth signature at (default 60). |
fee, timeoutSeconds, allowHttp | various | Standard transaction knobs. |
Returns Promise<SubmitResult>: { status, hash, … }; the tx hash lands on Stellar Expert on success.
import { sendSmartWalletTx } from '@soropass/core/sign';
import { Contract, Address, nativeToScVal } from '@stellar/stellar-sdk';
// The wallet sends 10 XLM to `dest`, authorized by the passkey.
const transfer = new Contract(NATIVE_SAC).call(
'transfer',
Address.fromString(walletContractId).toScVal(),
Address.fromString(dest).toScVal(),
nativeToScVal(10_0000000n, { type: 'i128' }),
);
const result = await sendSmartWalletTx({
operation: transfer,
networkPassphrase,
rpcUrl,
sourceSecret, // fee sponsor
sign, // the wallet's passkey
});The enforcing re-simulation replaces the old manual "raise the instruction budget + resource fee
before submit" step. It measures the real cost, including secp256r1_verify.
addSigner
Enroll a new passkey signer on a passkey-kit v1 smart-wallet on-chain, authorized by an existing device. Under the hood it builds add_signer(Signer::Secp256r1(...)) and runs it through sendSmartWalletTx. Mint the new device's signer with registerPasskey (register + extract, no deploy). For the v0.2 account's own recovery, see Native multi-device recovery.
addSigner(options: AddSignerOptions): Promise<SubmitResult>AddSignerOptions
Everything from SendSmartWalletTxOptions except operation, plus:
| Field | Type | Description |
|---|---|---|
walletContractId (required) | string | The smart-wallet C-address to add the signer to. |
newSigner (required) | NewDeviceSigner | { credentialId, publicKey, expiration?, storage? }: the new device's passkey. |
sign (required) | WebAuthnSigner | The existing device's passkey, which authorizes the change. |
NewDeviceSigner.credentialId accepts either a base64url string or raw bytes. expiration is an optional UNIX-seconds timestamp (v1); omit for a non-expiring recovery signer.
import { registerPasskey } from '@soropass/core/create';
import { addSigner } from '@soropass/core/recover';
// On the NEW device: register + extract, no deploy:
const device = await registerPasskey({ rpId, rpName: 'My Wallet', userName: 'alice' });
// Authorized by the EXISTING device's passkey:
const result = await addSigner({
walletContractId,
newSigner: device,
networkPassphrase,
rpcUrl,
sourceSecret,
sign: existingDeviceSigner,
});
// result.hash → the add_signer tx on Stellar ExpertremoveSigner
Remove a signer by credential id, authorized by an existing device. This is the symmetric operation to addSigner, with the same options but credentialId in place of newSigner.
removeSigner(options: RemoveSignerOptions): Promise<SubmitResult>The headless coreAddDevice helper composes registerPasskey → your
addSigner call into the add-device flow's prompting → binding → success states, so the flow
works with the styled screens or fully headless.
Adapters
Two pluggable seams, both small interfaces. The zero-infra default is direct submission + an events indexer; nothing else is required. A submission adapter takes a signed transaction XDR to the network; an indexer adapter maps a passkey credential back to its C-address. Swapping either is a one-line config change; the call site never moves.
Interfaces
interface SubmissionAdapter {
send(signedTxXdr: string): Promise<SubmitResult>;
}
interface SubmitResult {
status: 'SUCCESS' | 'PENDING' | 'FAILED';
hash: string;
returnValue?: unknown; // decoded contract return value on success
errorResultXdr?: string; // base64 XDR of the failure result when failed
}
interface IndexerAdapter {
resolveByCredential(credentialId: string): Promise<ResolvedAccount[]>;
}
// ResolvedAccount = { contractId: string; publicKey?: Uint8Array }
// publicKey is the founding SEC-1 key the factory `deployed` event reported, when present.SubmitResult field | Type | Description |
|---|---|---|
status | 'SUCCESS' | 'PENDING' | 'FAILED' | Terminal or in-flight state of the submission. |
hash | string | Transaction hash. |
returnValue | unknown? | Decoded contract return value on success (implementation-defined). |
errorResultXdr | string? | Base64 XDR of the failure result, present when status is 'FAILED'. |
Submission adapters
All three return SubmissionAdapter, so swapping one for another is a one-line change.
| Factory | Backend |
|---|---|
directSubmission(options) | Zero-infra default; sends straight to soroban-rpc (rpcUrl + networkPassphrase). |
launchtubeSubmission(options) | Legacy adapter for the Launchtube relay. The hosted Launchtube service is discontinued; use it only against a self-hosted deployment. |
openzeppelinRelayerSubmission(options) | OpenZeppelin Relayer, the production relayer path. |
import { directSubmission } from '@soropass/core';
import { Networks } from '@stellar/stellar-sdk';
const submission = directSubmission({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: Networks.TESTNET,
});
const res = await submission.send(signedTxXdr);
if (res.status === 'SUCCESS') console.log(res.hash);
if (res.status === 'FAILED') console.error(res.errorResultXdr);Indexer adapters
Both return IndexerAdapter and map a credential to its C-address.
| Factory | Backend |
|---|---|
eventsIndexer(options) | Zero-infra default; reads on-chain contract events (rpcUrl + factoryContractId). |
mercuryIndexer(options) | Optional Mercury index; the SDK never requires Mercury. |
import { eventsIndexer } from '@soropass/core';
const indexer = eventsIndexer({
rpcUrl: 'https://soroban-testnet.stellar.org',
factoryContractId: 'C...FACTORY',
});
const accounts = await indexer.resolveByCredential(credentialId);
console.log(accounts.map((a) => a.contractId));Deployers
An AccountDeployer turns a freshly registered passkey into a deployed smart account. Two ship with the SDK; supply your own for any other contract scheme.
| Factory | Backend |
|---|---|
factoryDeployer(options) | The on-chain AccountFactory: invokes factory.deploy(public_key, credential_id), which deploys a fresh webauthn-account (salted by sha256(credential_id ‖ public_key)) and emits the event the eventsIndexer resolves. |
smartWalletV1Deployer(options) | The passkey-kit v1 smart wallet (see v1 smart-wallet adapters). |
FactoryDeployerOptions:
| Field | Type | Description |
|---|---|---|
rpcUrl (required) | string | soroban-rpc endpoint used to simulate, assemble, and submit the deploy. |
networkPassphrase (required) | string | Network the deploy transaction targets. |
factoryContractId (required) | string | The deployed AccountFactory C-address. |
sourceSecret (required) | string | Secret of the account that pays the fee and signs the deploy transaction. The factory's deploy is not auth-gated, so this only sources the transaction; use an ephemeral funded key, never a key with value. |
allowHttp | boolean | Permit a plain-http RPC URL. Defaults to true only when rpcUrl starts with http://. |
fee | string | Base fee in stroops. Defaults to '2000000'. |
import { createPasskey, factoryDeployer } from '@soropass/core';
const account = await createPasskey({
rpId,
rpName,
userName,
deployer: factoryDeployer({ rpcUrl, networkPassphrase, factoryContractId, sourceSecret }),
});The factory scheme is deterministic: deriveAccountAddress computes the same C-address offline from the credential id and public key, so a returning user resolves their address with no network call.
v1 smart-wallet adapters
The concrete seams for the passkey-kit v1 smart-wallet, both exported from @soropass/core.
import {
smartWalletV1Deployer,
smartWalletV1Indexer,
SMART_WALLET_V1_WASM_HASH,
} from '@soropass/core';| Factory | Returns | Notes |
|---|---|---|
smartWalletV1Deployer(options) | AccountDeployer | Deploys a fresh v1 wallet from the canonical wasm via createCustomContract, salted by sha256(rawCredentialId) from a stable deployer, so the C-address is deterministic and offline-derivable (pair with deriveSmartWalletAddress for getAddress). Options: { rpcUrl, networkPassphrase, deployerSecret, wasmHash?, allowHttp?, fee? }. |
smartWalletV1Indexer(options) | IndexerAdapter | Resolves a credential id → wallet C-address from on-chain signer_added events (deploy + every add_signer). The zero-infra default for v1 recovery. Paginates from startLedger to the chain tip. Options: { rpcUrl, startLedger?, allowHttp? }. |
SMART_WALLET_V1_WASM_HASH is the canonical testnet v1 wasm hash the deployer defaults to: 84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319.
Recovery lookback window
The events indexer scans from startLedger (default: ~1 day back, latest - 17280 ledgers)
to the current ledger, so recently created wallets recover with no config, the common case. To
recover an older wallet, pass a larger startLedger; soroban-rpc keeps only a limited
retention window (a few days on testnet), and beyond it the events are gone. Use a persistent
index (mercuryIndexer). This applies to eventsIndexer too.
defaultAdapters
The zero-infra default stack: direct submission + events indexer, pre-wired.
defaultAdapters(options: DefaultAdapterOptions): { submission: SubmissionAdapter; indexer: IndexerAdapter }| Param | Type | Description |
|---|---|---|
options | DefaultAdapterOptions | Options for the default direct + events stack (rpcUrl + networkPassphrase + factoryContractId). |
import { defaultAdapters } from '@soropass/core';
import { Networks } from '@stellar/stellar-sdk';
const { submission, indexer } = defaultAdapters({
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: Networks.TESTNET,
factoryContractId: 'C...FACTORY',
});
const accounts = await indexer.resolveByCredential(credentialId);
const res = await submission.send(signedTxXdr);Invariant #4: pluggable adapters for submission + indexer; the default is zero-infra (direct + events). Because every factory returns the same interface, you can start on the default and move to the OZ Relayer or Mercury later without touching call sites.
SDK reference
The @soropass/core create + sign primitives, covering ES256-only registration, public-key extraction, passkey-signed Soroban auth entries, and the frozen error taxonomy.
Testing
The @soropass/core/testing entry, covering createPasskeyKit mock mode, the deterministic mockAuthenticator, the zero-IO in-memory backend, and sampleAuthEntry for ready-made auth entries.