Typed SDK for connecting web apps to the Beldex Wallet browser extension — connect, read the address and balance, and initiate transactions the user approves in the wallet, MetaMask-style.
Zero runtime dependencies. ESM + CJS + browser global builds. Amounts are BigInt end-to-end (1 BDX = 10⁹ atomic units — floats are never trusted).
Protocol spec:
PROTOCOL.md· Roadmap:IMPLEMENTATION_PLAN.md
npm install bdx-web3jsOr as a browser global: <script src="…/bdx-web3.global.js"></script> → window.BdxWeb3.
import { BeldexWeb3, detectProvider, toAtomic, fromAtomic } from 'bdx-web3js'
const provider = await detectProvider({ timeoutMs: 3000 })
if (!provider) {
// Extension not installed — show an "Install Beldex Wallet" CTA.
return
}
const bdx = new BeldexWeb3(provider)
// Connect (opens the wallet's approval UI; idempotent once granted)
const { address, network } = await bdx.connect()
// Balance — BigInt atomic units; format with fromAtomic()
const { unlocked, approximate } = await bdx.getBalance()
console.log(`spendable: ${fromAtomic(unlocked)} BDX${approximate ? ' (approx.)' : ''}`)
// Send — the user reviews and approves inside the wallet
try {
const { txHash, fee } = await bdx.sendTransaction({
to: 'bx…', // resolve BNS names first (see below)
amount: toAtomic('1.25'), // display BDX → atomic units
priority: 5 // 1 default … 5 flash (instant)
})
console.log('sent', txHash, 'fee', fromAtomic(fee))
} catch (e) {
if (BeldexWeb3.isUserRejection(e)) {
// user said no — not an error state
} else throw e
}
// Events
bdx.on('accountsChanged', () => {/* wallet switched — call connect() again */})
bdx.on('balanceChanged', d => {/* refresh UI */})
bdx.on('disconnect', () => {/* show connect button */})Ask the wallet to sign a plain-text message with the account spend key (the user approves in the wallet; "SigV1…" encoding, verifiable by beldex-wallet-cli and the explorer — see PROTOCOL.md §4.6):
const { signature, address } = await bdx.signMessage('I own this address — challenge #42')
// → { signature: 'SigV1…', address: 'bx…' }
// Verification is public — no connection or approval needed:
const valid = await bdx.verifyMessage({ message, address, signature })Constraints (enforced client-side before the request leaves the page, and again by the wallet): non-empty plain text only — no control characters (\n included) — and the reference wallet caps messages at 512 characters.
Ownership proof on connect. connectWithProof() connects and immediately has the wallet sign a domain-bound, single-line beldex-auth-v1 statement (two approvals back-to-back):
beldex-auth-v1 domain=<origin> uri=<origin+path> address=<addr> network=<net> nonce=<n> iat=<ms> exp=<ms>[ rid=<id>]
The page's origin is baked into the signed bytes, so a proof obtained by one site fails verification at any other relying party that checks domain. All-or-nothing by default: if the user declines the signature, the fresh connection is revoked again and the 4001 is rethrown.
// AUTHENTICATION — use a server-issued challenge (nonce made, tracked, and
// consumed single-use by YOUR backend):
const challenge = await fetch('/api/auth/challenge').then(r => r.json())
// → { nonce, requestId?, expiresInMs? }
const { proof } = await bdx.connectWithProof({ challenge }) // proof.serverIssued === true
await fetch('/api/auth/verify', { method: 'POST', body: JSON.stringify(proof) })
// Ownership/liveness signal only (SDK self-generates the nonce — do NOT
// accept these for login; the server can't tell the statement was made for
// its session):
const { proof: p2 } = await bdx.connectWithProof() // p2.serverIssued === false
// lenient variant — declined signature keeps the connection, proof is null:
await bdx.connectWithProof({ required: false })Server-side verification checklist: parse with parseAuthChallenge(message) (or rebuild via buildAuthChallenge(fields) from stored parts), then check all of: signature via bdx_verifyMessage/CLI verify_value · domain equals your origin · address is the claimed account · network is the expected chain · nonce was issued by you, unused, and consume it atomically · now within iat/exp. Reject anything that fails a single check. (The statement is single-line by design — the wallet rejects control characters, so multi-line SIWE-style layouts cannot be signed.) connectWithProof() obtains the statement via bdx_signAuthChallenge (wallet v1.2+ required): the wallet composes it from the origin it observes via the browser, so page-side code cannot forge the bound domain — and the reserved beldex-auth-v1 prefix is rejected in generic signMessage(), so an auth statement cannot be forged through raw message signing either. signAuthChallenge(params) is also exposed directly for custom flows.
sendTransaction accepts only concrete addresses — resolve names first so users approve exactly what gets paid:
const { address } = await bdx.resolveBns('shop.bdx')
// ALWAYS show `address` to the user before sending — resolution is not
// verified on-chain (verified: false).All failures throw BdxRpcError with a protocol code:
| Code | Meaning | Suggested UX |
|---|---|---|
| 4001 | User rejected | Neutral UI, no error toast |
| 4100 | Not connected | Call connect() |
| 4900 | Wallet locked | "Open your Beldex Wallet to continue" |
| 4901 | No wallet created | Point to wallet onboarding |
| 4999 | Request/approval expired or timed out | Safe to retry (reads/undecided approvals) |
| 4998 | Send outcome unknown (local timeout) | Do NOT resend blindly — use sendTransactionSafe() / getOperationStatus() |
| -32602 | Invalid params | Developer bug — check the message |
| -32603 | Internal wallet error | Retry later |
Helpers: BeldexWeb3.isUserRejection(e), .isLocked(e), .isUnauthorized(e), .isUnknownOutcome(e).
Every send automatically carries an idempotencyKey (or pass your own to make it resumable): retrying with the same key can never create a second approved payment — the wallet (v1.2+) replays the recorded outcome. A local timeout on a send throws 4998 UNKNOWN_OUTCOME (the transaction may still be broadcasting), never "expired":
const r = await bdx.sendTransactionSafe({ to, amount, idempotencyKey: orderId })
if (r.status === 'confirmed') {
console.log('paid', r.txHash, r.idempotent ? '(recovered, not re-sent)' : '')
} else {
// 'unresolved' — may still complete; re-run later with the SAME orderId,
// or poll bdx.getOperationStatus(operationId). Never treat as "not sent".
}toAtomic('1.25') // 1250000000n (display BDX → atomic)
fromAtomic(1250000000n) // '1.25' (atomic → display BDX)
checkAddress('bx…') // { valid, kind: 'standard' | 'integrated', reason? }checkAddress validates shape only (base58, bx prefix, length) — the wallet fully re-validates every address before signing.
Beldex is a private-by-default (Monero-family) chain, so this SDK is payments-oriented, and everything sensitive is permissioned:
- Balances aren't public —
getBalance()works only after the user approvesconnect(), and is answered by the wallet itself. Your app never sees the view key. - Every
sendTransaction/signMessagerequires fresh in-wallet approval. There are no allowances or auto-approvals. verifyMessageis the one keyless call: pure signature arithmetic, public, no grant.- Before
connect(), a page can only learn that a wallet exists (getState()).
import { BeldexProvider, ConnectButton, useConnect, useBalance, fromAtomic } from 'bdx-web3js/react'
function App() {
return (
<BeldexProvider>
<ConnectButton />
<Balance />
</BeldexProvider>
)
}
function Balance() {
const { isConnected } = useConnect()
const { balance } = useBalance({ pollMs: 15000 })
return isConnected && balance ? <p>{fromAtomic(balance.unlocked)} BDX</p> : null
}Hooks: useBeldex() (full context incl. the raw BeldexWeb3 client), useConnect(), useBalance({ pollMs }), useSignMessage() (sign(msg) resolves null on user rejection; signing/data/error state). Pass signOnConnect to <BeldexProvider> to run connectWithProof() on connect and expose the result as proof (via context and useConnect()). react >= 18 is an optional peer dependency — plain-JS users install nothing extra. See examples/react-demo.
detectProvider(opts?) · new BeldexWeb3(provider, opts?) · connect() · connectWithProof(opts?) · disconnect() · getAddress() · getBalance() · sendTransaction(params) · sendTransactionSafe(params, opts?) · getOperationStatus(operationId) · signMessage(msg) · signAuthChallenge(params) · verifyMessage(params) · resolveBns(name) · getNetwork() · getState() · buildAuthChallenge(address, nonce, ts) · on/off/once(event, fn) · address / isConnected getters.
Events: connect, disconnect, accountsChanged, networkChanged, balanceChanged, lock, unlock.
e2e/run.mjs drives the built extension in real Chromium over the real wire protocol: discovery, connect approve/reject, reads, client-side validation, and wallet-side revoke → disconnect event. Run on a desktop machine:
cd ../beldex-wallet-extension && npm run build:chrome
cd ../bdx-web3js && npm run build
npm i -D puppeteer # not committed: heavyweight Chromium download
npm run e2e # E2E_HEADED=1 to watch itSend-path testing requires a funded testnet wallet and a testnet LWS (CONFIG in the extension) — keep real sends manual.
npm install
npm run build # dist/: ESM + CJS + IIFE + .d.ts
npm test # vitest (jsdom) against a mock wallet
npm run typecheckexamples/vanilla.html runs against an inline mock wallet — open it after npm run build to try the full connect → balance → send flow with no extension installed. The mock steps aside automatically when the real extension is present.
Protocol v1 implemented end-to-end against the Beldex Wallet extension: connect (optionally with ownership proof), reads, user-approved sends, and message sign/verify (extension v1.1+; SigV1 wallet2::sign scheme). See CHANGELOG.md.