SmartLedger Wallet

Build on SmartLedger Wallet

Drop-in BSV sign-in for any web app. Identity-bound, server-verified, persistent — no OAuth provider, no email/password, no rented user accounts. The user holds the keys.

Quick start — 5 lines

Drop two lines into your <head>, three lines for the click handler. That's the whole integration.

<script src="https://wallet.smartledger.technology/sl-login.js"></script>

<button id="login">Sign in with SmartLedger</button>

<script>
  // On every page load: handle returning callbacks OR resume an existing session
  SLLogin.checkCallback().then(r => {
    if (r.status === 'ok')           showSignedIn(r.address)
    else if (SLLogin.session())      showSignedIn(SLLogin.session().address)
    else                             document.getElementById('login').onclick =
                                       () => SLLogin.start({ app: 'My App' })
  })
</script>

The user is redirected to the wallet's approval screen, sees the exact bytes they're being asked to sign, approves, and lands back on your page signed-in. The session persists for 7 days across browser sessions and tabs.

How it works

  1. Your site: calls SLLogin.start() → generates a 16-byte nonce, stores it in sessionStorage, redirects to the wallet's /login with the nonce and your callback URL.
  2. The wallet: shows an approval card with: your domain, app name, the exact signed payload, the identity address. User clicks Approve.
  3. The wallet signs "SmartLedger Wallet sign-in v1\nDomain: <your-host>\nNonce: <nonce>" with its BAP root key (m/424150'/0'/0'/0/0/0424150 = hex("BAP")). This is the same key that derives the user's portable BAP ID, so the signed-in identity resolves directly via SLProfile.
  4. Redirect back to your site with ?address=...&bapId=...&identityKey=...&signature=...&challenge=... (plus &ordAddress=... / &finAddress=... if your SLLogin.start() requested those scopes and the user approved them — see Receive-address scopes).
  5. First-time sign-in only: in the background, the wallet auto-publishes the user's BAP ID record to chain via our sponsored-publish endpoint (free to the user). After ~30–60s of indexer propagation, SLProfile.resolve(address) on your site returns a real profile (name + avatar). Subsequent sign-ins skip this step — the record is already on chain.
  6. Your site: calls SLLogin.checkCallback(). It verifies the nonce matches, posts {address, signature, challenge, domain} to the wallet's /api/verify-login, and on success stores a 7-day HMAC session token in localStorage.
  7. Subsequent page loads: SLLogin.session() returns the cached identity instantly; SLLogin.verifySession() optionally revalidates server-side.

Why this is a real OAuth replacement

SLLogin API reference

SLLogin.start(options)

Begin a sign-in. Generates a nonce, stores it in sessionStorage, redirects to the wallet's /login.

SLLogin.start({
  app: 'My App',                       // shown in the wallet's approval card
  redirectUri: 'https://my.app/cb',    // optional — defaults to current page
  authority: 'https://wallet.smartledger.technology', // optional override
  request: ['ordinals']                // optional — see "Receive-address scopes" below
})

Receive-address scopes

The optional request array asks the user, with explicit consent and a checkbox per scope on the approval card, to share additional public receive addresses alongside their identity signature. Known scopes today:

Unknown tokens are silently dropped — a future SDK requesting new scopes can't surprise users with mystery checkboxes. No private key material ever leaves the wallet. What flows on the callback is purely public address strings; the user always sees the address that would be shared and can uncheck per-scope before approving.

If you don't pass request, the SLLogin card is identical to what it was before — no extra rows, no extra prompts — and r.ordAddress / r.finAddress on the callback are null.

SLLogin.checkCallback(options) → Promise

Inspect the current URL for a sign-in callback; verify and store the session.

const r = await SLLogin.checkCallback()
// r.status is one of:
//   'ok'         — verified. r.address (BAP root address) / r.bapId / r.identityKey /
//                  r.signature / r.challenge / r.payload set; 7-day session stored.
//                  Plus r.ordAddress / r.finAddress when the matching scope was
//                  requested AND the user approved it; null otherwise.
//   'cancelled'  — user pressed Cancel in the wallet
//   'no_callback'— no callback params in URL (normal pageload)
//   'unverified' — { verify: false } was passed; do your own crypto with r.signature
//   'error'      — r.reason is one of: nonce_mismatch, invalid_signature, server_500, fetch_failed

Pass r.address straight to SLProfile.resolve() for the user's name/avatar. r.bapId is the deterministic ecosystem-portable identifier — safe to store in your DB as the user's primary key. r.ordAddress / r.finAddress (when present) are the user's receive addresses for inscriptions / BSV — stash them alongside the session for later sends.

SLLogin.session(options) → object | null

Synchronous. Returns the locally cached session, or null if expired/missing. No network call.

const s = SLLogin.session()
// { address: '1A1zP...', expiresAt: 1780420000000 }   or   null

SLLogin.verifySession(options) → Promise

Server round-trip — confirms the token is still valid and not revoked. Returns the same shape as session() on success, null on revoked/expired.

SLLogin.signOut(options) → Promise

Clear the local session. By default also revokes server-side so other tabs/devices lose access on their next verifySession().

await SLLogin.signOut()                       // local + server revoke (default)
await SLLogin.signOut({ everywhere: false })  // local only

SLLogin.onChange(callback) → unsubscribe

Subscribe to cross-tab sign-in/sign-out events on the same origin. Returns an unsubscribe function.

const off = SLLogin.onChange(e => {
  if (e.type === 'signedIn')  showSignedIn(e.address)
  if (e.type === 'signedOut') showSignedOut()
})

// later:  off()

SLLogin.cleanUrl()

Removes ?address=&signature=&challenge= from location.search via history.replaceState — so refreshes don't re-process the callback.

SLLogin.clear()

Wipes the in-flight nonce only (not the session). Call on your own logout if you want to abandon a pending sign-in.

SLAttest — structured-payload signing

Sister API to SLLogin. Asks the user to sign an arbitrary payload string with their BAP root key (m/424150'/0'/0'/0/0/0) — same redirect-based UX, same domain-binding, same nonce protection. The address returned on the callback is the same address as SLLogin would return for that user, so a server that's already verified an SLLogin session can verify an SLAttest signature against the same identity.

SLAttest.start(options)

SLAttest.start({
  app: 'My App',
  payload: JSON.stringify(workEnvelope),       // canonical bytes (≤ 4 KB)
  redirectUri: 'https://my.app/work/done'      // optional — defaults to current page
})

SLAttest.checkCallback(options) → Promise

const r = await SLAttest.checkCallback()
// r.status:
//   'ok'         — verified; r.address / r.signature / r.payload / r.signedMessage set
//   'cancelled'  — user pressed Cancel in the wallet
//   'no_callback'— no attest params in the URL
//   'unverified' — { verify: false } was passed; do your own bsv.Message.verify
//   'error'      — r.reason: nonce_mismatch, invalid_signature, server_500, fetch_failed

The full bytes signed (and which the verifier must reconstruct) are:

SmartLedger Wallet attest v1
App: <app name>
Domain: <hostname of redirect_uri>
Nonce: <random nonce>
Payload: <exact payload string passed to SLAttest.start>

Canonicalisation is the caller's responsibility — pass a stable string (e.g. sorted-key JSON).

SLAttest.cleanUrl()

Same shape as SLLogin.cleanUrl() — strips ?address=&signature=&nonce= from the URL bar after handling the callback.

SLPublish — self-funded OP_RETURN broadcast

Have the user's wallet build, sign, and broadcast an OP_RETURN transaction funded by their own BSV. Same redirect-based UX as SLAttest; the wallet shows every push, the funding address, and the estimated fee before broadcasting.

For zero-cost-to-user publishing, use POST /api/sponsored-publish from your backend instead — same field-array format.

SLPublish.start(options)

SLPublish.start({
  app: 'My App',
  outputs: [
    { fields: ['// hex strings, 1-20 per output, 1-4 outputs total'] }
  ],
  redirectUri: 'https://my.app/published'
})

SLPublish.checkCallback() → Promise

const r = await SLPublish.checkCallback()
// r.status:
//   'ok'         — broadcast; r.txid set
//   'cancelled'  — user pressed Cancel
//   'no_callback'— no publish params in URL
//   'error'      — r.reason: nonce_mismatch

Wallet UX

On /publish?… the wallet shows:

If the Finance address is unfunded, the Approve button stays disabled and the user is told to either send BSV or have the integrator use sponsored publish.

SLProfile — BAP profile resolution

Wraps the BAP indexer call so integrators don't need to know about bap.network. Returns a stable shape regardless of indexer format quirks.

SLProfile.resolve(addressOrBapId, opts) → Promise

// Pass either a BSV address (the wallet shell loads bsv.bundle.js, so the
// shortcut works there) or a BAP ID directly (works in any third-party page).
const profile = await SLProfile.resolve('2aWfpccxfqLpUwLjSXxnmbDM9Bs6')
// → {
//     bapId, address, identityKey, currentAddress,
//     name, image, description, pubkey       // any may be null
//   } | null on miss

SLProfile.resolveBapId(bapId, opts) → Promise

Explicit BAP-ID-only variant; doesn't need window.bsv. Use this from any third-party page.

SLProfile.bapIdFromAddress(address) → string | null

Synchronous BAP ID derivation. Returns null when window.bsv isn't available.

Indexer override

await SLProfile.resolve(bapId, { indexer: 'https://my-bap-indexer.example' })

Defaults to https://bap.network. For multi-indexer resilience, wrap with your own racer.

Server endpoints

If you don't want to load sl-login.js on every page (or you want to verify from your backend), call the wallet's HTTP API directly. CORS is wide-open on these endpoints.

POST /api/verify-login

// Request
{
  "address":   "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
  "signature": "H1nVOTGki...",         // base64
  "challenge": "",
  "domain":    "your-site.com"         // must match what the wallet signed
}

// Response — verified
{
  "valid":   true,
  "payload": "SmartLedger Wallet sign-in v1\nDomain: your-site.com\nNonce: ...",
  "token":   "eyJhIjoi...",            // 7-day HMAC session token
  "address": "1A1z...",
  "exp":     1780420000,               // unix seconds
  "ttl":     604800
}

POST /api/check-session

// Request
{
  "token":  "eyJhIjoi...",
  "domain": "your-site.com"            // MUST be the audience the token was issued for
}

// Response
{ "valid": true,  "address": "1A1z...", "exp": 1780420000 }
{ "valid": false }
{ "valid": false, "reason": "revoked" }

POST /api/verify-attest

// Request
{
  "address":   "1A1z…",
  "signature": "H1n…",
  "payload":   "<exact payload string the wallet signed>",
  "app":       "My App",
  "domain":    "your-site.com",
  "nonce":     "<random nonce from the URL callback>"
}

// Response
{ "valid": true,  "signedMessage": "<full bound message>" }
{ "valid": false }

POST /api/revoke-session

Mark a token as invalid before its natural expiration. Idempotent. Requires the audience domain so a hostile caller can't blast random tokens.

// Request
{ "token": "eyJhIjoi...", "domain": "your-site.com" }

// Response
{ "revoked": true, "alreadyInvalid": false }   // killed
{ "revoked": true, "alreadyInvalid": true  }   // token was already invalid (idempotent OK)

Rate limits

All three endpoints share a 600/hour/IP budget. CORS preflight (OPTIONS) returns 204 with permissive headers.

Security model

Signed payload

SmartLedger Wallet sign-in v1
Domain: <hostname of redirect_uri>
Nonce: <challenge>

This exact string is what gets signed with the user's BAP root key (m/424150'/0'/0'/0/0/0) using the standard "\x18Bitcoin Signed Message:\n" envelope (BIP-137-style). The domain is in the signed bytes, so a signature is bound to the audience that requested it — a signed challenge minted for siteA.com cannot be replayed against siteB.com's verify-login endpoint.

Session tokens

HMAC-SHA256 of JSON({a:address, d:audience, e:expEpochSec}), base64url-encoded as payload.sig. The audience is checked on every verify. Stateless — the wallet keeps no record of issued tokens beyond a revocation list.

Threat model

Headers the wallet sets on every response

For integrators auditing what they're loading from us, every wallet response carries:

Strict-Transport-Security:  max-age=31536000; includeSubDomains
X-Content-Type-Options:     nosniff
Referrer-Policy:            strict-origin-when-cross-origin
X-Frame-Options:            DENY                       // no iframe embedding
Content-Security-Policy:    frame-ancestors 'none'     // modern equivalent
Permissions-Policy:         camera=(), microphone=(), geolocation=(), payment=(), usb=(), …
Cross-Origin-Opener-Policy: same-origin                // isolate browsing context

No Cross-Origin-Embedder-Policy or Cross-Origin-Resource-Policy headers — both would break cross-origin loading of sl-login.js and bsv.bundle.js, which is exactly the behaviour we want to allow.

What integrators should set on their own pages

The wallet's anti-iframing posture doesn't protect your site if it can be iframed. Set the same clickjacking defenses on the page that loads sl-login.js:

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

// And keep your own CSP for XSS defense; allow our origin
// in your script-src / connect-src as needed:
Content-Security-Policy:
  script-src 'self' https://wallet.smartledger.technology;
  connect-src 'self' https://wallet.smartledger.technology https://bap.network;
  frame-ancestors 'none';

Subresource integrity (SRI) for our scripts

sl-login.js is updated frequently (the most recent shipped on 2026-06-02). If you pin an SRI hash, the script will start failing the moment we update — usually within the same week. For now we recommend not pinning SRI on sl-login.js; trust the origin via TLS + HSTS instead.

bsv.bundle.js is pinned per-bsv-version and changes less often. If you want SRI for it, fetch the current hash and pin it in your <script integrity="…"> tag; expect to update it on our 4.0.x roll-forward (post-VG-smoke). A versioned, stable-SRI distribution endpoint is on the v2 roadmap.

BAP identity

SmartLedger Wallet derives an ecosystem-portable BAP ID for every user. It's the same identifier Yours Wallet, bsv-bap, and other BAP-aware tools recognise — your users don't need to "register" anywhere, the ID is deterministic from their mnemonic.

// Derivation
rootChild   = HD.derive("m/424150'/0'/0'/0/0/0")  // 424150 = hex("BAP")
rootAddress = rootChild.privateKey.toAddress()
BAP_ID      = Base58( RIPEMD160( SHA256( asciiBytes(rootAddress) ) ) )

Today the wallet displays the BAP ID under the Identity purpose; users can copy it and reference it in their profile across any BSV-ecosystem service.

Publishing on-chain

The wallet can inscribe an initial BAP ID record so the ecosystem can discover the identity exists. Format matches the canonical bsv-bap library:

OP_RETURN
  1BAPSuaPnfGnSBM3GLV9yhxUdYe4vGbdMT     // BAP Bitcom prefix
  ID
  <identityKey>                          // the BAP ID
  <currentAddress>                       // m/424150'/0'/0'/0/0/1
  |
  15PciHG22SNLQJXMoSUaWVi7WSqc7hCfva     // AIP Bitcom prefix
  BITCOIN_ECDSA
  <rootAddress>                          // m/424150'/0'/0'/0/0/0
  <sig>                                  // bsv.Message.sign of OP_RETURN+'|'

Automatic for most users. The first time a user signs in to any third-party app via SLLogin, the wallet auto-publishes their BAP ID record via /api/sponsored-publish — no fee paid by the user, no manual step. After ~30–60s the record is indexed by bap.network and resolvable by SLProfile.resolve() across the ecosystem (Yours Wallet, etc.).

Manual publish is still available under the Identity tab in the wallet — useful for users who haven't signed in to any third-party app yet, or who want to publish a richer profile (name + avatar + bio) in one go. Two buttons: Publish (sponsored) uses our sponsor wallet at no cost; Publish (your funds) uses the active Finance address (≈250 sat fee).

Profile attestations

Each profile field (name, avatar, bio) is published as a separate ATTEST+DATA OP_RETURN, AIP-signed by the BAP current key (m/424150'/0'/0'/0/0/1). All fields go out in one transaction with one output per field.

URN              = "urn:bap:id:<attr>:<value>:<random nonce>"
attrHash         = sha256(URN).hex
attestation      = "bap:attest:" + attrHash + ":" + identityKey
attestationHash  = sha256(attestation).hex

OP_RETURN
  1BAPSuaPnfGnSBM3GLV9yhxUdYe4vGbdMT  ATTEST  <attestationHash>  <counter>  |
  1BAPSuaPnfGnSBM3GLV9yhxUdYe4vGbdMT  DATA    <attestationHash>  <URN>       |
  15PciHG22SNLQJXMoSUaWVi7WSqc7hCfva  BITCOIN_ECDSA  <currentAddress>  <sig>

Conventional attribute keys (matches Yours Wallet / schema.org Person): name, image, description. Avatars can be a regular HTTPS URL or a 1sat://txid.0 reference.

Encrypted messaging (ECIES)

The wallet's Encrypt & decrypt messages section lets users encrypt a message to any BSV public key. Anything using the standard bsv-ecies library — Yours Wallet, bsv-bap-encrypted-messages, RelayX — can decrypt it.

// From any tool with bsv-ecies
const ct = new bsv.ECIES()
  .privateKey(senderPriv)
  .publicKey(recipientPub)
  .encrypt('hello')

// Recipient decrypts in our wallet by pasting the base64 ciphertext

The receiver's compressed public key is shown in their wallet under My public key for easy sharing.

About the user wallet