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
- Your site: calls
SLLogin.start()→ generates a 16-byte nonce, stores it in sessionStorage, redirects to the wallet's/loginwith the nonce and your callback URL. - The wallet: shows an approval card with: your domain, app name, the exact signed payload, the identity address. User clicks Approve.
- The wallet signs
"SmartLedger Wallet sign-in v1\nDomain: <your-host>\nNonce: <nonce>"with its BAP root key (m/424150'/0'/0'/0/0/0—424150= hex("BAP")). This is the same key that derives the user's portable BAP ID, so the signed-in identity resolves directly viaSLProfile. - Redirect back to your site with
?address=...&bapId=...&identityKey=...&signature=...&challenge=...(plus&ordAddress=.../&finAddress=...if yourSLLogin.start()requested those scopes and the user approved them — see Receive-address scopes). - First-time sign-in only: in the background, the wallet auto-publishes the user's BAP
IDrecord 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. - 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. - Subsequent page loads:
SLLogin.session()returns the cached identity instantly;SLLogin.verifySession()optionally revalidates server-side.
Why this is a real OAuth replacement
- No provider accounts. You don't register an OAuth client; users don't sign up with a third party.
- Domain-bound signatures. A signature minted for
your-site.comcannot be replayed againstanother-site.com— the domain is in the signed bytes. - Real revocation.
SLLogin.signOut()revokes the token server-side, so other tabs / devices / browsers lose access immediately. - Cross-tab consistency. Sign-in or sign-out in one tab fires a
BroadcastChannelevent; other tabs update without polling. - Open verification. If you don't trust our server, pass
{ verify: false }tocheckCallbackand verify withbsv.Message.verify(payload, address, signature)yourself.
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:
'ordinals'— the user's 1Sat receive address (HDm/44'/236'/2'/0/0). Useful when your site needs to send the user inscriptions and would otherwise have to ask them to paste an address.'finance'— the user's BSV payment receive address (the active Finance account). Useful for sites that want to send the user funds.
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:
- Requesting site + app name
- Funding address (active Finance account)
- Live fee estimate (sats + ~USD)
- Every push, rendered ASCII or hex with byte counts — the user sees exactly what gets inscribed
- Approve & broadcast / Cancel
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
- Replay across sites: blocked by Domain in signed payload.
- Nonce reuse: integrator stores the nonce in sessionStorage;
checkCallbackverifies it matches and clears it. A second redirect with the same nonce will fail. - Token theft from localStorage: mitigated by 7-day TTL and revocation. If the user's device is compromised, you have the same threat profile as any session-cookie scheme.
- Malicious wallet operator: can mint sessions for addresses they don't control? No — the signature must verify against the address. The HMAC only proves the wallet's server saw a valid signature, not who controls the address.
- Wallet downtime: existing sessions keep working until
verifySession()is called. If you only usesession()(local check), users stay signed in indefinitely while wallet is offline. - Clickjacking against the wallet's
/loginapproval card: blocked byX-Frame-Options: DENY+Content-Security-Policy: frame-ancestors 'none'. The wallet refuses to render in any iframe; an attacker cannot overlay a transparent button over the wallet's Approve button to trick users into signing.
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
- Non-custodial — mnemonic generated and stored client-side; the server only ever sees AES-GCM ciphertext blobs.
- Email-gated encrypted backup — opt-in. PBKDF2(200k) over
password+'|'+email; OTP via email guards both upload and restore. - Three purposes from one mnemonic — Identity (BIP44 m/44'/236'/0'), Finance (m/44'/0'/0'), Ordinals (m/44'/236'/2').
- Auto-aggregating Send — after a wallet scan, sends pool UTXOs across every funded address.
- Paymail send — type
alice@handcash.ioin the recipient field; the wallet resolves to the BSV address via our server-side proxy (GET /api/paymail-resolve) and the user pays as normal. - QR receive + scan, message signing, vault password rotation — full self-custody UX.