SoroPass
SDK

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

FieldTypeDescription
rpId (required)stringRelying Party ID for the WebAuthn ceremony.
indexer (required)IndexerAdapterResolves the credential to its C-address(es).
webauthnWebAuthnClientOptional WebAuthn client override (defaults to the platform navigator.credentials).
storageCredentialStorageWhere the stored credential id is read from.
publicKeyUint8ArrayThe 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.
silentMediationSupportedbooleanOverride the isConditionalMediationAvailable probe.

ConnectResult

Promise<ConnectResult | null>: null means no stored credential or no account; fall through to recover().

FieldTypeDescription
contractIdstringThe resolved smart-account C-address.
credentialIdstringThe 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

FieldTypeDescription
rpId (required)stringRelying Party ID for the WebAuthn ceremony.
indexer (required)IndexerAdapterResolves the recovered credential to its C-address(es).
webauthnWebAuthnClientOptional WebAuthn client override (defaults to the platform navigator.credentials).
challengeUint8ArrayOptional 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.

FieldTypeDescription
contractIdstringA smart-account C-address controlled by the credential.
credentialIdstringThe 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.

native-add-device.ts
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).

FunctionReturnsDescription
applyAssertionToSmartWalletEntry(entry, assertion)SorobanAuthorizationEntryReturns 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.ScValSignerKey::Secp256r1(raw credential id bytes) as an ScVal.
buildSmartWalletSignatureVariant(assertion)xdr.ScValThe Signature::Secp256r1 { authenticator_data, client_data_json, signature } variant for one assertion.
compareSignerKeyScVal(a, b)numberThe host's canonical element-wise ScVal byte comparator; sort map keys with it.
buildSecp256r1Signer(spec)xdr.ScValEncodes Signer::Secp256r1 from { credentialId, publicKey, expiration?, storage? }; validates the 65-byte SEC-1 key.
buildAddSignerOperation(options)xdr.OperationThe wallet-authorized add_signer(Signer) invocation for { walletContractId, signer }.
buildRemoveSignerOperation(options)xdr.OperationThe 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:

  1. recording simulation → assemble (discovers the wallet's auth requirement);
  2. sign the wallet auth entry with the passkey (binding the expiration ledger);
  3. 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;
  4. pay the fee from the classic source and submit via the pluggable adapter.
sendSmartWalletTx(options: SendSmartWalletTxOptions): Promise<SubmitResult>

SendSmartWalletTxOptions

FieldTypeDescription
operation (required)xdr.OperationThe wallet-authorized invocation (a SAC transfer, a contract call, …).
networkPassphrase (required)stringBound into the auth challenge.
rpcUrl (required)stringsoroban-rpc endpoint used for simulate/assemble.
sourceSecret (required)stringFunded classic account that pays the fee + sequences the tx (sponsor only).
sign (required)WebAuthnSignerThe wallet's passkey signer that authorizes the invocation.
submissionSubmissionAdapterWhere the signed tx is sent. Defaults to direct (soroban-rpc).
verifySignVerifyOptionsOpt-in pre-flight validation of the assertion.
signatureExpirationLedgerOffsetnumberLedgers ahead of latest to expire the auth signature at (default 60).
fee, timeoutSeconds, allowHttpvariousStandard transaction knobs.

Returns Promise<SubmitResult>: { status, hash, … }; the tx hash lands on Stellar Expert on success.

payment.ts
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:

FieldTypeDescription
walletContractId (required)stringThe smart-wallet C-address to add the signer to.
newSigner (required)NewDeviceSigner{ credentialId, publicKey, expiration?, storage? }: the new device's passkey.
sign (required)WebAuthnSignerThe 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.

add-device.ts
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 Expert

removeSigner

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 fieldTypeDescription
status'SUCCESS' | 'PENDING' | 'FAILED'Terminal or in-flight state of the submission.
hashstringTransaction hash.
returnValueunknown?Decoded contract return value on success (implementation-defined).
errorResultXdrstring?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.

FactoryBackend
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.

FactoryBackend
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.

FactoryBackend
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:

FieldTypeDescription
rpcUrl (required)stringsoroban-rpc endpoint used to simulate, assemble, and submit the deploy.
networkPassphrase (required)stringNetwork the deploy transaction targets.
factoryContractId (required)stringThe deployed AccountFactory C-address.
sourceSecret (required)stringSecret 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.
allowHttpbooleanPermit a plain-http RPC URL. Defaults to true only when rpcUrl starts with http://.
feestringBase 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';
FactoryReturnsNotes
smartWalletV1Deployer(options)AccountDeployerDeploys 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)IndexerAdapterResolves 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 }
ParamTypeDescription
optionsDefaultAdapterOptionsOptions 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.

On this page