BASIS / TECHNICAL DOCUMENTATION
Private rails.
Public standard.
Build private receiving, unified portfolios, safe spending, and selective disclosure for tokenized real-world assets.
Overview
A privacy layer for tokenized real-world assets. Private by default, disclosable on demand.
Contents
| Page | What it covers |
|---|---|
| Introduction | What Basis is, how it differs from a mixer, architecture |
| Concepts | Stealth addresses, the two-key model, announcements, disclosure |
| Quickstart | Runnable code for every core flow |
| API Reference | Every exported function across all packages |
| Running Infrastructure | Contracts, indexer, relayer, web app |
| Reference | Addresses, selectors, wire formats, constants |
| Best Practices | Getting the most out of the privacy model |
In one minute
You publish one permanent handle. Every payment sent to it arrives at a brand new address that only you can control, with no visible link to your handle or to any other payment.
You find your money by scanning announcements with a viewing key that never leaves your device. The service distributing those announcements has no way to ask who you are — it cannot answer "which of these are mine?" because the question does not exist in its API.
When you need to prove what you hold, you issue a signed statement covering exactly the addresses you choose, bound to exactly one recipient. They verify control cryptographically and read the balances from the chain. They gain no ability to spend and no visibility into anything else.
Basis implements ERC-5564 and ERC-6538 against the canonical singleton contracts, verified byte-for-byte against the reference implementation in both directions. Payments from any standard-compliant wallet are spendable in Basis, and payments Basis creates are recoverable by other tooling.
Packages
@basis/stealth cryptographic core, announcements, registry, token metadata
@basis/indexer distributes announcements without learning who is scanning
@basis/wallet accounts, vault, portfolio, privacy analysis, disclosure, signing
@basis/relay supplies gas to addresses that arrived without it
@basis/app browser interface
Introduction
Basis is a privacy layer for tokenized real-world assets.
Public blockchains make every position readable by everyone, permanently. For memecoins that is a curiosity. For a portfolio of tokenized equities, treasuries, or credit it is a financial disclosure the holder never agreed to make: position sizes, entry timestamps, cost basis, and every subsequent move, available to anyone who can link a single address to a person.
Basis closes that gap without giving up the properties that make public settlement worth having.
Private by default. Disclosable on demand. Your choice, every time.
What Basis provides
Unlinkable receiving. You publish one permanent handle. Every payment sent to it arrives at a brand new address that has never existed before, with no on-chain relationship to your handle, to you, or to any other payment you have received.
A unified portfolio. Your holdings end up spread across many addresses that deliberately have nothing in common. Basis reassembles them into a single balance sheet, computed on your device from your own keys.
Selective disclosure. Prove exactly what you hold, to exactly one party, for exactly one purpose. Recipients verify control cryptographically and read balances from the chain. They gain no ability to spend and no visibility into anything you did not include.
Guardrails that hold. The wallet examines every transfer before it is signed and blocks the actions that would undo your privacy. Links on a public ledger are permanent, so the check happens first rather than after.
How it differs from a mixer
Mixers pool everyone's funds, break the link, and provide no way to re-establish it. That design makes lawful disclosure impossible along with everything else.
Basis works the other way:
- No pooling. Funds are never commingled. Every address derives from your own keys and is controlled only by you. There is no shared pot and no dependence on anyone else's behaviour.
- Disclosure is a first-class feature, not an omission.
- The record persists. Everything still settles publicly. You choose who can read it.
The mental model is a bank account. Your bank does not publish your balance. It also does not pretend your account does not exist.
Standards
Basis implements ERC-5564 (stealth addresses, scheme ID 1) and ERC-6538 (the stealth meta-address registry), using the canonical singleton contracts deployed at the same addresses on every EVM chain.
This is deliberate and load-bearing. Basis is verified byte-for-byte against the ecosystem reference implementation in both directions: payments generated by any standard-compliant wallet are spendable in Basis, and payments Basis generates are recoverable by other tooling. Your funds are not dependent on Basis continuing to exist.
Architecture
Basis is five packages. Each is independently useful and independently testable.
| Package | Purpose |
|---|---|
@basis/stealth |
Cryptographic core: key derivation, stealth addresses, scanning, announcements, registry, token metadata |
@basis/indexer |
Distributes announcements to clients without learning who is scanning |
@basis/wallet |
Accounts, encrypted vault, portfolio, privacy analysis, disclosure, transaction signing |
@basis/relay |
Supplies gas to addresses that arrived without it |
@basis/app |
Browser interface |
The dependency direction is one way: stealth depends on nothing, indexer and wallet depend on stealth, app depends on all of them.
Where to go next
- Concepts — how stealth addresses actually work
- Quickstart — receive and spend your first payment
- API reference — every exported function
- Running infrastructure — operate an indexer or relayer
- Reference — contracts, wire formats, constants
- Best practices — getting the most out of the privacy model
Concepts
Everything in Basis rests on one construction. Understanding it takes about five minutes and makes the rest of the system obvious.
Two keys, not one
A Basis account has two keypairs rather than the usual one.
The spending key controls funds. It never leaves your device and is required to move anything.
The viewing key detects payments. It can determine which announcements on the chain belong to you, and it can do nothing else. It cannot move a single token.
This separation is what makes the whole system work. Detection and control are cryptographically independent, which means the ability to see can be delegated without delegating the ability to spend.
The meta-address
Your public handle is both public keys concatenated:
st:eth:0x0275154e9a7375cbbc651e157659cf74e447ebce76fc60704f3ed8ee
a1aa556b4903cbca7f8f8cd8cfe77acd71325233117fad413136e5f6d
94403468bba797c34f1
└── spending public key ──┘└── viewing public key ──┘
Publish it anywhere: a website, a profile, an invoice. It reveals nothing about what you hold, and it never needs to change.
In practice users rarely see this string. Registering it against a normal Ethereum address via ERC-6538 means senders type 0xAlice… and resolution happens underneath.
How a payment works
ONE PUBLIC HANDLE / ONE NEW ADDRESS PER PAYMENT
spendPub + viewPubECDH → shared secret → tweak0x9af2…4373 + announcementThe sender discards the ephemeral private key. The public ledger sees a new recipient with no visible route back to the published handle.
When someone pays you, their wallet:
- Generates a one-time ephemeral keypair, used for this payment only
- Performs ECDH between the ephemeral private key and your viewing public key, producing a shared secret only the two of you can compute
- Hashes that secret into a tweak
- Adds the tweak to your spending public key:
stealthPub = spendPub + tweak·G - Derives an Ethereum address from the result and sends the funds there
- Publishes an announcement containing the ephemeral public key and a one-byte view tag
- Discards the ephemeral private key
The result is an address that has never existed before, that only you can derive the private key for, and that carries no visible relationship to your handle or to any other payment.
Ten people paying you produces ten addresses that look like ten unrelated strangers.
How you find your money
You compute the same shared secret from the other side: ECDH between your viewing private key and the published ephemeral public key produces the identical tweak. Add it to your spending key and you have the key that controls the address:
stealthPriv = (spendPriv + tweak) mod n
Nobody without your viewing key can perform step one, and nobody without your spending key can perform step two.
The view tag
Checking every announcement requires an ECDH operation, which is the expensive part. The view tag is the first byte of the hashed shared secret, published alongside the announcement. It lets a scanner discard roughly 255 out of every 256 foreign announcements after the cheap portion of the work, before any point arithmetic.
Scanning without revealing anything
The critical property: scanning happens on your device. Every client downloads the same public announcement data and matches locally.
A service that scanned on your behalf would need your viewing key, and would then hold a complete map of who owns what. Basis is built so this is structurally impossible rather than merely discouraged — the announcement service has no endpoint that accepts a key, an address, or any user identifier, so the question "which of these are mine?" cannot be expressed in the API.
Scanning cannot spend
Identifying your payments requires your viewing key and the public half of your spending key. That is sufficient to compute the stealth address and compare it:
stealthPub = spendPub + tweak·G // exactly what the sender computed
So scanning code returns tweaks, and only the main thread — where the spending key lives — combines a tweak with the spending key to produce something that can move funds. A compromised scanner learns which payments are yours. It cannot take them.
Announcements
An announcement is an event emitted by the canonical ERC-5564 announcer contract. Its complete contents:
schemeId 1
stealthAddress 0x9af2c8d6411d90074ac7a9960a96589a1ab14373
caller 0xb0b0000000000000000000000000000000000000
ephemeralPubKey 02f2db3a95ea1ffd…815023
metadata 85 a9059cbb 1f9840a8…f984 000…0de0b6b3a7640000
│ │ │ └── amount
│ │ └── token address
│ └── function selector
└── view tag
Your identity, your meta-address, and the sender-to-you link appear nowhere.
Metadata matters more than it looks
Without metadata, discovering an address tells you that you were paid but not with what. Finding out would mean querying a node about every token you might have received, for every address you own — hundreds of requests that hand the node exactly the correlation the system exists to prevent.
With metadata, the announcement itself names the asset and the amount. The wallet knows what arrived before it talks to anyone.
The portfolio index
Unlinkability creates a bookkeeping problem: your holdings are spread across addresses that deliberately have nothing in common, and only you can tell they are related.
The index is that map, held encrypted on your device. Two properties are enforced:
It stores tweaks, never stealth private keys. A tweak plus your spending key yields a spendable key; a tweak alone yields nothing. The index by itself moves no funds.
Links are recorded permanently. Every spend that publicly joins two addresses is written into the index and never removed, so your anonymity picture is computed from what actually happened rather than from an optimistic assumption. Five addresses that have touched each other are one identity, not five.
Disclosure
A disclosure is an explicit, signed statement about specific holdings.
- You choose exactly which addresses to include
- Each carries a control proof: a signature from that address's own key
- The bundle is signed by your spending key, tying every address to your meta-address
- Amounts are not taken on trust — the recipient reads real balances from the chain
- Each disclosure is bound to a one-time code the recipient supplies, so it cannot be reused to prove your holdings to anyone else
Future payments are not covered, because they did not exist when the statement was signed.
Glossary
| Term | Meaning |
|---|---|
| Meta-address | Your permanent public handle: spending + viewing public keys |
| Stealth address | A one-time address derived for a single payment |
| Ephemeral key | The sender's one-time keypair, discarded after use |
| Tweak | Hashed ECDH shared secret; added to the spending key to derive control |
| View tag | One byte enabling a fast reject of ~255/256 foreign announcements |
| Announcement | On-chain event telling a recipient where to look |
| Viewing key | Detects payments. Cannot spend |
| Spending key | Controls funds |
| Index | Your encrypted local map of discovered addresses |
| Linkage | A public connection between two addresses, created by spending them together |
| Disclosure | A scoped, signed, verifier-bound statement of holdings |
Quickstart
Working code for every core flow. All examples run in Node or the browser.
Install
npm install @basis/stealth @basis/wallet @basis/indexer
The cryptographic core has two dependencies, both audited and widely used: @noble/curves and @noble/hashes.
Create an account
import { createRandom, saveAccount, unlockAccount, localStorageAdapter } from '@basis/wallet';
const storage = localStorageAdapter(); // or memoryStorage(), or fileStorage(dir)
await saveAccount(storage, createRandom(46630), 'a strong passphrase');
const account = await unlockAccount(storage, 'a strong passphrase');
console.log(account.metaAddress);
// st:eth:0x0275154e…bba797c34f1
The vault is encrypted with AES-256-GCM under a scrypt-derived key. Nothing is sent anywhere.
Signature-derived accounts
An account can also be derived from a wallet signature, so it is reproducible on any device with nothing to back up:
import { accountSigningRequest, createFromSignature } from '@basis/wallet';
const { typedData } = accountSigningRequest(46630);
const sig = await wallet.signTypedData(typedData.domain, typedData.types, typedData.message);
const sig2 = await wallet.signTypedData(typedData.domain, typedData.types, typedData.message);
// Two signatures are required and must derive identically. Some wallets produce
// non-deterministic ECDSA; catching that at onboarding is trivial, catching it
// after a deposit is not.
const contents = await createFromSignature({ signature: sig, verifySignature: sig2, chainId: 46630 });
await saveAccount(storage, contents, 'a strong passphrase');
Signature-derived accounts store no private keys at all — only public keys and the index. Unlocking requires the passphrase and the wallet signature.
Receive a payment
Publish account.metaAddress. Optionally register it on-chain so senders can use a normal address instead:
import { buildRegisterKeysCalldata, ERC6538_REGISTRY } from '@basis/stealth';
const calldata = buildRegisterKeysCalldata(account.metaAddress);
// send { to: ERC6538_REGISTRY, data: calldata }
Sender side
import { generateStealthAddress, buildAnnounceCalldata, encodeTransferPayload,
buildErc20TransferCalldata, ERC5564_ANNOUNCER } from '@basis/stealth';
const payment = generateStealthAddress(recipientMetaAddress);
// 1. send the tokens
tx({ to: TOKEN, data: buildErc20TransferCalldata(payment.stealthAddress, amount) });
// 2. include gas so the recipient can move them without help
tx({ to: payment.stealthAddress, value: 10n ** 16n });
// 3. announce, naming the asset so the recipient learns what arrived
tx({ to: ERC5564_ANNOUNCER, data: buildAnnounceCalldata({
...payment,
extraMetadata: encodeTransferPayload([{ token: TOKEN, amount }]),
}) });
Attaching gas is recommended by ERC-5564 itself and is the cleanest solution to the funding problem.
Find your payments
import { syncAndScan } from '@basis/indexer';
import { mergeDiscovered, normaliseIndex, summarise } from '@basis/wallet';
let index = normaliseIndex(account.index);
const scan = await syncAndScan({
indexerUrl: 'https://index.example.com',
viewingPrivateKey: account.keys.viewingPrivateKey, // never transmitted
spendingPrivateKey: account.keys.spendingPrivateKey, // never transmitted
state: { syncedPages: index.syncedPages, matches: [] },
onProgress: (p) => console.log(`page ${p.page}/${p.of}`),
});
const merged = mergeDiscovered(index, scan.matches.map((m) => ({
stealthAddress: m.stealthAddress,
ephemeralPublicKey: m.ephemeralPublicKey,
viewTag: m.viewTag,
stealthPrivateKey: m.stealthPrivateKey, // converted to a tweak, then dropped
})), account.keys.spendingPrivateKey);
index = { ...merged.index, syncedPages: scan.syncedPages };
console.log(summarise(index));
// { totals: { … }, addressCount: 3, fundedAddressCount: 3,
// strandedCount: 0, anonymity: { addresses: 3, independentSets: 3, largestCluster: 1 } }
Sealed pages are immutable, so syncedPages is a permanent cursor. Sync to page 40 today, fetch 41+ tomorrow.
Check a transfer before signing it
WALLET STATE / UNLINKED
import { analyseSpend, toHoldings } from '@basis/wallet';
const report = analyseSpend({
inputs: toHoldings(index),
destination: MY_MAIN_WALLET,
gasSource: 'external-wallet',
knownIdentityAddresses: [MY_MAIN_WALLET],
});
console.log(report.safe); // false
for (const f of report.findings) console.log(`[${f.severity}] ${f.message} → ${f.remedy}`);
[critical] Paying for gas from your main wallet would publicly connect it to this
stealth address, and to the payment it received.
→ Have the sender include gas with the payment, or use a relayer.
[critical] Spending from 3 stealth addresses in one transaction proves they belong
to the same person.
→ Send from one address at a time, to different destinations.
[critical] The destination is an address you have told us is publicly linked to you.
→ Send to a fresh address instead.
safe === false means at least one critical finding. Treat it as blocking.
The safe version
import { safestPlan } from '@basis/wallet';
const plan = safestPlan(toHoldings(index)[0], freshDestination, { hasRelayer: true });
analyseSpend(plan).safe; // true
Spend
A stealth address is an EOA no wallet software knows about, so Basis signs transactions itself:
import { jsonRpc, sendFromStealthAddress, waitForReceipt } from '@basis/wallet';
import { spendKeyFor, recordSpend } from '@basis/wallet';
import { buildErc20TransferCalldata } from '@basis/stealth';
const rpc = jsonRpc('https://rpc.example.com');
const entry = index.entries[0];
const key = spendKeyFor(entry, account.keys.spendingPrivateKey);
const sent = await sendFromStealthAddress({
rpc, from: entry.address, privateKey: key,
to: TOKEN,
data: buildErc20TransferCalldata(destination, amount),
chainId: 46630,
});
await waitForReceipt(rpc, sent.hash);
// Record what this made public, so the anonymity picture stays truthful
index = recordSpend(index, [entry.address], destination);
Nonce and gas price are read from the node. Gas is estimated with 20% headroom.
Issue a disclosure
PRIVATE PORTFOLIO / HOLDER SELECTS
SIGNED PROOF / RECIPIENT RECEIVES
- 2 selected control proofs
- One-time verifier binding
- Purpose: 2026 tax return
- Seven-day expiry
- Balances read from chain
Unselected holdings and future payments remain private. The verifier gains no ability to spend.
import { createDisclosure, verifyDisclosure, newVerifierBinding } from '@basis/wallet';
// The recipient generates this and gives it to you first
const binding = newVerifierBinding();
const disclosure = createDisclosure({
index,
addresses: [index.entries[0].address, index.entries[1].address], // only these
spendingPrivateKey: account.keys.spendingPrivateKey,
spendingPublicKey: account.keys.spendingPublicKey,
viewingPublicKey: account.keys.viewingPublicKey,
chainId: 46630,
purpose: '2026 tax return',
verifierBinding: binding,
ttlMs: 7 * 24 * 3600 * 1000,
});
The recipient verifies, then reads balances themselves:
const result = verifyDisclosure(disclosure, binding, { expectedChainId: 46630 });
// { valid: true, provenAddresses: ['0x…', '0x…'], problems: [] }
for (const address of result.provenAddresses) {
const balance = await rpc.call('eth_call', [
{ to: TOKEN, data: buildBalanceOfCalldata(address) }, 'latest',
]);
}
Save state
import { saveIndex } from '@basis/wallet';
account.index = index;
await saveIndex(storage, account, passphrase);
The index is as sensitive as the keys and receives the same encryption.
API Reference
Every exported function, grouped by package.
@basis/stealth
The cryptographic core. No dependencies beyond @noble/curves and @noble/hashes.
Keys and meta-addresses
generateStealthKeys(): StealthKeys
Fresh spending and viewing keypairs, generated locally.
{ spendingPrivateKey, spendingPublicKey, viewingPrivateKey, viewingPublicKey } // all hex, no 0x
deriveStealthKeysFromSignature(signature: string): StealthKeys
Derives both keypairs from ≥64 bytes of signature entropy, using domain-separated hash chains. Deterministic: the same signature always yields the same account.
encodeMetaAddress(spendingPublicKey, viewingPublicKey): StealthMetaAddress
Returns { metaAddress, spendingPublicKey, viewingPublicKey } where metaAddress is st:eth:0x<132 hex>.
decodeMetaAddress(metaAddress): { spendingPublicKey, viewingPublicKey }
Parses and validates. Both halves are checked as real curve points; throws otherwise.
toChecksumAddress(address: string): string
EIP-55 checksumming.
Sending
generateStealthAddress(metaAddress, ephemeralPrivateKey?): StealthAddressResult
Sender side. Returns { stealthAddress, ephemeralPublicKey, viewTag }. Pass a fixed ephemeral key only in tests.
Scanning
checkAnnouncement(announcement, viewingPrivateKey, spendingPrivateKey): ScanMatch | null
Checks one announcement. Returns { …announcement, stealthPrivateKey } on a match, null otherwise. Malformed input returns null rather than throwing.
scanAnnouncements(announcements[], viewingPrivateKey, spendingPrivateKey): ScanMatch[]
Batch form. Survives hostile input.
scanBinary(records, ctx, offset?, count?): BinaryMatch[]
Binary-native scanner operating directly on 54-byte page records. No hex allocation. Returns tweaks rather than spendable keys.
makeScanContext({ viewingPrivateKey, spendingPublicKey }): ScanContext
Builds a scan context from the viewing private key and spending public key. Rejects a private key supplied where the public one belongs.
scanContextFromKeys({ viewingPrivateKey, spendingPrivateKey }): ScanContext
Convenience: derives the spending public key for you.
spendKeyFromTweak(spendingPrivateKey, tweak): string
Combines a tweak with the spending key to produce the controlling key. Call only where the spending key lives.
tweakFromKeys(spendingPrivateKey, stealthPrivateKey): Uint8Array
Recovers the tweak from a stealth private key, so an index can store the non-spendable form.
scanParallel(records, keys, opts?): Promise<PoolScanResult>
Spreads scanning across CPU cores via worker threads. Falls back to in-thread below 2048 records or on a single core.
scanParallelBrowser(records, keys, workerUrl, opts?): Promise<PoolScanResult>
Browser equivalent. Supply the worker URL, since bundlers resolve worker assets differently.
Announcements
buildAnnounceCalldata({ stealthAddress, ephemeralPublicKey, viewTag, extraMetadata?, schemeId? }): string
ABI-encoded calldata for announce().
decodeAnnouncementLog(log): DecodedAnnouncement | null
Decodes one log. Returns null for anything that is not a scheme-1 announcement, or is malformed.
decodeAnnouncementLogs(logs[]): DecodedAnnouncement[]
Batch form; drops anything unparseable.
announcementLogFilter(fromBlock, toBlock?)
Builds an eth_getLogs filter targeting the canonical announcer and scheme 1.
Registry (ERC-6538)
metaAddressToRegistryBytes(uriOrHex, chainShortName?): MetaAddressParts
Converts the st:eth:0x… form into the raw 66 bytes the registry stores, validating both curve points.
registryBytesToMetaAddress(raw, chainShortName?): MetaAddressParts | null
Reverse. Returns null for an absent registration; throws if a registration exists but is malformed.
buildRegisterKeysCalldata(metaAddress, schemeId?): string
The registrant pays gas.
buildRegisterKeysOnBehalfCalldata({ registrant, metaAddress, signature, schemeId? }): string
Gasless registration, relayed by a third party.
registrationTypedData(req) / registrationDigest(req)
EIP-712 payload and digest for gasless registration. Pass the typed data to the wallet so the user sees structured fields.
recoverRegistrationSigner(req, signature): string | null
verifyRegistrationSignature(req, signature, expectedSigner): boolean
Verify locally before broadcasting; an invalid signature reverts on-chain and wastes gas.
resolveMetaAddress(ethCall, registrant, opts?): Promise<ResolutionResult | null>
Resolves an address to its registered meta-address. Returns null when unregistered.
crossCheckRegistration(ethCall, getLogs, registrant, opts?)
Resolves via storage and event history and reports whether they agree. Two independent read paths raise the bar for a hostile node.
buildStealthMetaAddressOfCall, buildNonceOfCall, buildIncrementNonceCalldata, decodeBytesReturn, decodeUintReturn, decodeRegistrationLog, registrationLogFilter, computeDomainSeparator
Lower-level building blocks.
Tokens
encodeTransferMetadata(viewTag, transfers[]): string
Full ERC-5564 metadata: view tag followed by one 56-byte record per asset.
encodeTransferPayload(transfers[]): string
Records only, for callers supplying the view tag separately. This is what goes in extraMetadata.
decodeTransferMetadata(metadata): DecodedMetadata | null
decodeTransferPayload(payload): { transfers, trailing }
Tolerant by design: senders write this field and are not required to follow the convention. Unparseable bytes become trailing rather than an exception, because malformed metadata must never hide a real payment.
transfersToBalances(transfers[]): Record<string, string>
Sums transfers per asset as bigints.
buildErc20TransferCalldata(to, amount), buildErc20TransferFromCalldata(from, to, amount), buildBalanceOfCalldata(owner), buildDecimalsCalldata(), buildSymbolCalldata()
decodeUint256Return(data), decodeStringReturn(data)
decodeStringReturn handles both the dynamic-string and legacy bytes32 forms.
Key derivation
keyDerivationTypedData(chainId) / keyDerivationDigest(chainId)
The canonical EIP-712 message a wallet signs to create a Basis account. Chain-bound. This message is permanent — altering it would re-derive every existing account.
@basis/indexer
Client
getStatus(indexerUrl, fetchImpl?): Promise<IndexerStatus>
{ version, pageCapacity, totalAnnouncements, sealedPages, totalPages, tipCount,
cursorBlock, head, confirmations }
syncAndScan(opts): Promise<SyncState>
Downloads every page and scans locally. Keys are used in-process and never transmitted; two clients with different keys produce byte-identical request sequences.
{ indexerUrl, viewingPrivateKey, spendingPrivateKey, state?, fetchImpl?, onProgress? }
→ { syncedPages, matches }
Only sealed pages advance the cursor. Unsealed pages are re-fetched each sync. If sealed history shrinks below a stored cursor, the client resyncs from scratch.
estimateSync(status, syncedPages?): { pages, announcements, bytes }
Sync cost, for display before a mobile download.
Wire format
encodePage(header, records): Uint8Array / decodePage(buf): DecodedPage
Fixed-width binary, 54 bytes per record after a 32-byte header. The decoder treats the server as untrusted: every structural claim is checked against actual byte length, so a truncated page throws rather than silently returning fewer records.
Server
createPageServer(store, opts): { server, listen, close }
{ port?, host?, confirmations?, getHead? }
Pages seal only once every record in them is below the confirmation depth. Without a head source, nothing seals.
IndexerStore(path, opts?)
SQLite-backed storage using Node's built-in driver. { blockTimeMs?, reorgWindow? }.
Ingestor(store, opts)
Chunked eth_getLogs ingestion with reorg detection.
{ rpcUrl, startBlock, chunkSize?, confirmations?, announcerAddress?, fetchImpl? }
reorgWindowBlocks(blockTimeMs): number
Blocks to retain for one hour of chain history, clamped.
@basis/wallet
Vault
seal(contents, passphrase, opts?): Promise<SealedVault> / open(vault, passphrase)
AES-256-GCM with a scrypt-derived key. The header is authenticated, so KDF parameters cannot be altered undetected. Every vault stores the parameters it was created with, so changing defaults never locks out an existing user.
rekey(vault, oldPassphrase, newPassphrase, kdf?)
memoryStorage(), localStorageAdapter(), fileStorage(dir) (from @basis/wallet/vault-node)
saveVault(storage, key, vault) / loadVault(storage, key)
loadVault throws on an unreadable vault rather than reporting absence — reporting "no account" over the top of a corrupted one would invite creating a new account over existing funds.
DEFAULT_KDF — scrypt N=2¹⁷ (128MB) · MOBILE_KDF — N=2¹⁶ (64MB)
Account
createRandom(chainId): AccountContents
createFromSignature({ signature, verifySignature, chainId }): Promise<AccountContents>
Requires two signatures and refuses unless they derive identically.
accountSigningRequest(chainId): { typedData, digest }
saveAccount(storage, contents, passphrase, opts?)
unlockAccount(storage, passphrase, { signature?, key? }): Promise<UnlockedAccount>
saveIndex(storage, account, passphrase, opts?)
changePassphrase(storage, old, new, opts?)
accountExists(storage, key?) / lockAccount(account)
Portfolio
emptyIndex() / normaliseIndex(raw)
mergeDiscovered(index, discovered[], spendingPrivateKey, now?): { index, added }
Idempotent by address. Accepts either a tweak or a stealth private key; keys are converted to tweaks immediately and never retained.
applyBalances(index, updates[], now?)
recordSpend(index, inputs[], destination?)
Records every pairwise link the spend creates. Append-only.
spendKeyFor(entry, spendingPrivateKey): string
summarise(index): PortfolioSummary
{ totals, addressCount, fundedAddressCount, strandedCount, anonymity, lastSyncAt }
toHoldings(index, asset?) / clusters(index)
Privacy
analyseSpend(plan): PrivacyReport
Pure and synchronous, so the check cannot be skipped by an unawaited promise.
{ inputs, destination, amount?, gasSource, knownIdentityAddresses?, now? }
→ { findings, safe, worst }
Codes: GAS_FROM_KNOWN_WALLET, GAS_SOURCE_UNKNOWN, NO_GAS_AT_SOURCE, CONSOLIDATION, DESTINATION_IS_KNOWN_ADDRESS, DESTINATION_IS_INPUT, EXACT_AMOUNT_SWEEP, TIMING_CORRELATION, MIXED_ASSETS.
Every finding carries a remedy. safe === false means at least one critical.
safestPlan(holding, destination, { hasRelayer })
Builds the safest transfer available. Never proposes external-wallet gas.
linkageClusters(addresses, linkedPairs) / anonymityScore(addresses, linkedPairs)
shuffle(items, rnd?)
Uniform Fisher-Yates. Used to order balance queries so the sequence carries no information about discovery order.
Sync
sync(opts): Promise<SyncResult>
Scans, then optionally refreshes balances through an injected BalanceSource. Queries are shuffled, batched, and optionally jittered.
balancePrivacyWarning(source, addressCount)
A plain statement of what the current balance configuration exposes.
Disclosure
createDisclosure(args): Disclosure
{ index, addresses[], spendingPrivateKey, spendingPublicKey, viewingPublicKey,
chainId, purpose, verifierBinding, registrant?, ttlMs?, now? }
verifierBinding is required, not optional — without it a recipient could reuse the statement elsewhere.
verifyDisclosure(disclosure, expectedBinding, opts?): VerificationResult
{ valid, provenAddresses, problems, metaAddress?, registrant?, purpose? }
newVerifierBinding(): string
A recipient generates this and hands it to the discloser first.
exportViewingKey(keys): ViewingKeyExport
Raw viewing key for continuous-monitoring engagements, with the scope statement attached to the key itself.
Transactions
signLegacyTx(tx, privateKey): { raw, hash }
EIP-155 legacy transactions. Verified byte-for-byte against an independent implementation.
sendFromStealthAddress(args): Promise<SendResult>
{ rpc, from, privateKey, to, value?, data?, gasLimit?, chainId }
Reads nonce and gas price from the node; estimates gas with 20% headroom.
waitForReceipt(rpc, hash, opts?)
Throws on revert. On timeout, reports that the transaction may still confirm rather than claiming failure.
jsonRpc(url, fetchImpl?), rlpEncode, toQuantity, legacySigningHash
@basis/relay
requestGas(opts, address, detail?): Promise<RelayResponse>
Requests gas for one address. The signature takes a single address deliberately: an array-accepting form would be used, and using it would tell the relayer those addresses share an owner.
fetchQuote(opts): Promise<RelayQuote>
Relayer(opts) — service logic
createRelayServer(relayer, opts) (from @basis/relay/server)
rpcChain(opts): RelayChain
validateRequest, computeDrip, buildQuote, decide, DEFAULT_POLICY, relayPrivacyNote
Running Infrastructure
Basis needs two services to be useful at scale, plus the canonical contracts on your chain. All three are optional in the sense that clients can fall back to reading a node directly — but an indexer makes mobile sync practical, and a relayer rescues payments that arrived without gas.
Contracts
Basis uses the canonical ERC-5564 and ERC-6538 singletons, deployed at the same addresses on every EVM chain:
| Contract | Address |
|---|---|
| ERC5564Announcer | 0x55649E01B5Df198D18D95b5cc5051630cfD45564 |
| ERC6538Registry | 0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538 |
| CREATE2 deterministic deployer | 0x4e59b44847b379578588920ca78fbf26c0b4956c |
Why the canonical addresses matter
Every ERC-5564 tool hardcodes those vanity addresses. Deploying an announcer at a random address would fork Basis off the standard. Using CREATE2 with the canonical salt and init code reproduces the exact same contracts at the exact same addresses, keeping Basis interoperable with everything else in the ecosystem.
Check whether they exist
cd basis-deploy
npm install
node check.mjs
== mainnet (chainId 4663)
CREATE2 deployer 0x4e59b448…c0b4956c DEPLOYED (69 bytes)
ERC5564 Announcer 0x55649E01…cfD45564 NOT DEPLOYED
ERC6538 Registry 0x6538E6bf…ce5d6538 NOT DEPLOYED
Deploy what is missing
$env:PRIVATE_KEY = "0x<throwaway key with gas>"
node deploy.mjs testnet # always testnet first
node check.mjs testnet
node deploy.mjs mainnet
Remove-Item Env:\PRIVATE_KEY
The deployer key needs gas money only. Neither contract has an owner, an admin, or any privileged role tied to whoever deploys it.
How you know the kit is honest: a CREATE2 address is pure math —
address = keccak256(0xff ++ deployer ++ salt ++ keccak256(initCode))[12:]
deploy.mjs recomputes this before sending anything and aborts on mismatch. If the init code were tampered with, the deployment would land at a different address. It is not possible to place wrong code at the canonical address.
If the CREATE2 deployer is absent
Some young chains lack it. Bootstrap with Nick's method:
- Send exactly 0.01 ETH to
0x3fab184622dc19b6109349b94811493bf2a45362 - Broadcast the chain-agnostic presigned transaction (see
basis-deploy/README.md) - Re-run
deploy.mjs
Many Orbit chains pre-deploy the proxy in genesis, so check first.
The announcement service (indexer)
What it does
Ingests Announcement events and serves them as immutable, numbered pages. Clients download every page and match locally.
What it deliberately cannot do
There is no endpoint that accepts a key, an address, or any user identifier. The service cannot answer "which of these are mine?" because the question cannot be expressed in the API. That is enforced by the API surface and asserted by a test.
Run it
cd basis-indexer
npm install
RPC_URL=https://rpc.example.com \
START_BLOCK=<announcer deployment block> \
PORT=8787 \
node --import tsx daemon.mjs
Under pm2, with a reverse proxy terminating TLS:
pm2 start daemon.mjs --name basis-indexer --node-args="--import tsx"
Configuration
| Variable | Default | Notes |
|---|---|---|
RPC_URL |
required | Chain RPC |
START_BLOCK |
required | Announcer deployment block |
DB_PATH |
./basis-index.db |
SQLite file |
PORT |
8787 |
|
HOST |
127.0.0.1 |
Put nginx or Caddy in front |
CHUNK_SIZE |
5000 |
Blocks per eth_getLogs |
POLL_MS |
2000 |
Between sync passes |
START_BLOCK matters. On a chain producing ~100ms blocks (roughly 864k per day), starting from zero wastes hours scanning empty history.
API
| Endpoint | Returns | Cache |
|---|---|---|
GET /v1/status |
counts, sealed and total page counts, cursor block | 5s |
GET /v1/pages/:n |
page n, binary |
1 year immutable when sealed, 2s otherwise |
GET /v1/tip |
the final partial page | 2s |
Every page that exists is served. Only pages entirely below the confirmation depth carry the immutable header — cache immutability is a promise about settled history, so it is made only about history that has settled.
Reverse proxy
Put nginx or Caddy in front. It handles TLS, rate limiting, and compression better than application code, and rate limiting is a deployment requirement rather than an optional extra.
index.example.com {
reverse_proxy 127.0.0.1:8787
encode gzip
rate_limit { zone basis { key {remote_host} events 120 window 1m } }
}
Sealed pages are immutable and byte-identical for every client, so a CDN absorbs most traffic and the origin often never sees the request.
Reorg handling
The indexer keeps a rolling window of block hashes — sized in time, defaulting to one hour of chain history — and walks backwards comparing them against the chain each pass. On divergence it rewinds and re-ingests.
Clients handle the deep case: if sealedPages ever shrinks below a stored cursor, the client discards local state and resyncs rather than trusting a mix of old and new history.
The relayer
What it does
Sends gas to a stealth address that holds value but cannot pay to move it. Without this, the only way to move such funds is to fund the address from a wallet of your own, which relinks it.
The better answer is for senders to attach gas with the payment, which ERC-5564 recommends and Basis supports. The relayer covers the case where they did not.
Design
One address per request. Batching would tell the relayer that several addresses share an owner, so the protocol has no way to express it.
No accounts, no auth, no address logging.
No local database of funded addresses. The per-address cap is enforced against public chain history, so the relayer never builds the correlation ledger it is trusted not to keep.
Proof of need instead of identity. Rate limiting by IP or account is identity-adjacent. Instead the relayer verifies on-chain that the address genuinely holds something and genuinely lacks gas. Spamming then requires actually moving tokens to each address, which costs far more than the drip is worth.
Run it
import { Relayer, rpcChain } from '@basis/relay';
import { createRelayServer } from '@basis/relay/server';
const chain = rpcChain({
rpcUrl: process.env.RPC_URL,
relayerAddress: wallet.address,
historyFromBlock: DEPLOY_BLOCK,
send: async (to, value) => (await wallet.sendTransaction({ to, value })).hash,
});
const relayer = new Relayer({ chain, defaultToken: process.env.TOKEN });
const svc = await createRelayServer(relayer, { port: 8788 });
await svc.listen();
Policy
{
gasLimit: 120_000n, // gas units a drip must cover
gasPriceBufferPct: 150n, // headroom over observed price
perAddressCapWei: 5n * 10n**15n, // lifetime maximum per address
alreadyFundedThresholdWei: 10n**14n,
reserveWei: 10n**16n, // relayer keeps this back
quoteTtlMs: 60_000,
}
API
| Endpoint | Purpose |
|---|---|
GET /v1/quote |
Current drip size and mode |
POST /v1/gas |
Request gas for one address |
Responses distinguish funded, already-funded, and rejected, so a wallet knows whether retrying is sensible. Every rejection carries a reason the user can act on.
The web app
cd basis-app
npm install
npm run build # bundles the libraries to public/basis.js
npm run smoke # browser-compatibility check
npm run serve # http://127.0.0.1:5173
Deploy public/ as static files. There is no backend: the vault lives in the browser's own storage and keys never leave the device.
Configure the chain RPC, announcement service, relayer, and asset address under Network in the app.
public/basis.js is a build artifact and is not committed — a stale bundle that looks current would run old library code while the source beside it says otherwise.
Reference
Constants, formats, and on-chain specifics. Everything here is verified against deployed bytecode or an independent implementation.
Contracts
| Contract | Address | Notes |
|---|---|---|
| ERC5564Announcer | 0x55649E01B5Df198D18D95b5cc5051630cfD45564 |
Same on every EVM chain |
| ERC6538Registry | 0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538 |
Same on every EVM chain |
| CREATE2 deployer | 0x4e59b44847b379578588920ca78fbf26c0b4956c |
Deterministic deployment proxy |
Function selectors
Every value below was confirmed present in the deployed contract bytecode, not merely computed.
| Function | Selector |
|---|---|
announce(uint256,address,bytes,bytes) |
0x4d1f9583 |
registerKeys(uint256,bytes) |
0x042c7aa3 |
registerKeysOnBehalf(address,uint256,bytes,bytes) |
0x428d3d0b |
stealthMetaAddressOf(address,uint256) |
0x7aa8b5ad |
nonceOf(address) |
0xed2a2d64 |
incrementNonce() |
0x627cdcb9 |
transfer(address,uint256) |
0xa9059cbb |
transferFrom(address,address,uint256) |
0x23b872dd |
balanceOf(address) |
0x70a08231 |
Event topics
| Event | topic0 |
|---|---|
Announcement(uint256,address,address,bytes,bytes) |
0x5f0eab8057630ba7… |
StealthMetaAddressSet(address,uint256,bytes) |
0x4e739a47dfa4fd3c… |
Scheme ID
Basis uses scheme ID 1 (secp256k1, per ERC-5564). Announcements with any other scheme ID are ignored.
Meta-address format
Off-chain (URI form):
st:eth:0x<66 bytes hex>
└ spending pubkey (33) ┘└ viewing pubkey (33) ┘
On-chain (what the registry stores): the raw 66 bytes, no prefix, no URI. The chain is implicit from which registry the record lives in.
Convert with metaAddressToRegistryBytes() and registryBytesToMetaAddress().
Announcement metadata (ERC-5564 convention)
The metadata parameter carries the view tag plus one 56-byte record per asset.
byte 1 view tag
bytes 2-5 function identifier (4 bytes)
bytes 6-25 token contract address (20 bytes)
bytes 26-57 amount, or token ID for non-fungibles (32 bytes)
Total: 57 bytes for a single transfer. Additional 56-byte records may follow.
Native asset: identifier 0xeeeeeeee, address 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE (ERC-7528 placeholder).
Tokens: identifier is the real function selector.
85 a9059cbb 1f9840a85d5aF5bf1D1762F925BDADdC4201F984 00…0de0b6b3a7640000
│ │ │ └── 1.0 (18 decimals)
│ │ └── token contract
│ └── transfer(address,uint256)
└── view tag
Decoding is deliberately tolerant: senders write this field and are not obliged to follow the convention, so unparseable bytes are returned as trailing rather than raised as an error. Malformed metadata must never prevent a recipient from discovering a real payment.
Indexer page format
Fixed-width binary. A page is a 32-byte header followed by a flat array of records with no delimiters.
Header (32 bytes, big-endian)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic 0x42415349 ("BASI") |
| 4 | 1 | version |
| 5 | 1 | flags — bit 0 = sealed |
| 6 | 2 | reserved |
| 8 | 4 | page index |
| 12 | 4 | record count |
| 16 | 8 | from block |
| 24 | 8 | to block |
Record (54 bytes)
| Offset | Size | Field |
|---|---|---|
| 0 | 20 | stealth address |
| 20 | 33 | ephemeral public key (compressed) |
| 53 | 1 | view tag |
Page capacity: 8192 records ≈ 442 KB.
Binary rather than JSON because clients must download every announcement to preserve privacy — asking for a subset is what leaks. JSON hex costs roughly 160 bytes per announcement against 54 here. At 500,000 announcements that is 27 MB instead of 80 MB, which decides whether a mobile sync is viable.
Vault format
{
"header": {
"version": 1,
"kdf": { "algorithm": "scrypt", "N": 131072, "r": 8, "p": 1, "dkLen": 32 },
"salt": "<16 bytes hex>",
"createdAt": 0,
"updatedAt": 0
},
"iv": "<12 bytes hex>",
"ciphertext": "<AES-GCM output including the 16-byte tag>"
}
- KDF: scrypt, default N=2¹⁷ (128 MB, ~200ms on current desktop hardware).
MOBILE_KDFuses N=2¹⁶ for constrained environments. - Cipher: AES-256-GCM via WebCrypto, fresh 12-byte IV per write.
- AAD: the serialised header, so recorded parameters cannot be altered undetected.
Every vault stores the parameters it was created with, and decryption always uses those rather than current defaults. Changing a default must never lock out an existing user.
EIP-712 domains
Account key derivation
domain { name: "Basis", version: "1", chainId }
type BasisAccountKey(string warning,uint256 version)
Chain-bound, so a signature harvested for one chain cannot unlock an account on another. This message is permanent — its digest is pinned to constants in the test suite, because altering it would re-derive every existing account.
Registry (ERC-6538)
domain { name: "ERC6538Registry", version: "1.0", chainId, verifyingContract }
type Erc6538RegistryEntry(uint256 schemeId,bytes stealthMetaAddress,uint256 nonce)
The domain separator is computed locally rather than read from the contract: a hostile node could otherwise return a separator for a domain the user did not intend.
Disclosure format
{
version: 1,
purpose: "2026 tax return",
verifierBinding: "<one-time code from the recipient>",
issuedAt: 0,
expiresAt: 0,
chainId: 46630,
metaAddress: "st:eth:0x…",
registrant: "0x…",
items: [{
stealthAddress, ephemeralPublicKey, asset?, amount?, blockNumber?,
controlProof: "<65-byte [r][s][v] signature by that address's key>"
}],
signature: "<65-byte signature by the spending key over the body digest>"
}
Verification:
- Recompute the body digest and recover the signer; it must match the spending public key inside
metaAddress - For each item, recover the control proof signer; it must equal the claimed stealth address
- Confirm
verifierBindingmatches the code the verifier issued - Read balances from the chain — amounts in the document are advisory
Control challenge binds chain ID, meta-address, stealth address, purpose, verifier binding, and issue time, so a proof cannot be lifted into another bundle or replayed to a different recipient.
Transaction signing
Basis signs EIP-155 legacy (type 0) transactions. A stealth address is an EOA no wallet software knows about, so there is nothing to delegate signing to.
Legacy rather than EIP-1559 because every EVM chain accepts it, including older L2s and test networks. A stealth sweep is a single small transfer where fee optimisation is worth little and compatibility is worth a lot.
signing hash = keccak256(rlp([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]))
v = chainId * 2 + 35 + recovery
Verified byte-for-byte against an independent implementation across five transaction shapes.
Constants
| Constant | Value |
|---|---|
SCHEME_ID_SECP256K1 |
1n |
PAGE_CAPACITY |
8192 |
RECORD_SIZE (page) |
54 |
HEADER_SIZE (page) |
32 |
METADATA_BYTES |
57 |
RECORD_BYTES (metadata) |
56 |
REORG_WINDOW_MS |
3600000 (one hour) |
TIMING_CORRELATION_WINDOW_MS |
3600000 |
VAULT_VERSION |
1 |
DISCLOSURE_VERSION |
1 |
PROTOCOL_VERSION (relay) |
1 |
NATIVE_IDENTIFIER |
eeeeeeee |
NATIVE_TOKEN_ADDRESS |
0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE |
Performance
Measured on constrained hardware; a modern laptop core runs several times faster.
| Operation | Cost |
|---|---|
| ECDH per announcement (constant-time) | ~2.2 ms |
ECDH per announcement (multiplyUnsafe) |
~1.0 ms |
| Binary scan, single core | ~500 announcements/sec |
| Scan with worker pool | scales close to linearly with cores |
| Page fetch, 8192 records | 442 KB |
| Vault unlock, N=2¹⁷ | ~200 ms |
The scan path uses variable-time point multiplication deliberately: the ephemeral point is public chain data, and the operation runs on the user's own device where no external observer can time it. Key generation and the sender path remain constant-time.
Best Practices
Stealth addresses give you unlinkability. Keeping it is a matter of a few habits, and Basis is built to make the right ones the default. This page explains what those habits are and why they work, so you can reason about anything the wallet has not automated yet.
Receiving
Ask senders to include gas
The single highest-value habit. A payment that arrives with a small gas stipend can be moved entirely on its own, with no relayer and no funding transaction.
ERC-5564 recommends this in its own security considerations, and Basis supports it directly:
tx({ to: TOKEN, data: buildErc20TransferCalldata(payment.stealthAddress, amount) });
tx({ to: payment.stealthAddress, value: 10n ** 16n }); // the stipend
tx({ to: ERC5564_ANNOUNCER, data: buildAnnounceCalldata({ ...payment, extraMetadata }) });
If you are building a payment product, make this the default in your integration. It costs the sender a fraction of a cent and removes an entire class of problem for the recipient.
Include metadata in announcements
Naming the asset and amount lets a recipient learn what arrived from the announcement alone, with no node queries. Fewer queries means fewer opportunities for an RPC provider to correlate addresses.
Register your meta-address
Registering via ERC-6538 lets senders type a normal address instead of a 132-character string. Better usability, and no privacy cost: the registration reveals only that an address has a Basis account, not what it holds.
Spending
The wallet analyses every transfer before signing, so in normal use these are handled for you. Understanding them helps when you are building on the libraries directly.
One address per transaction
Spending from several stealth addresses in one transaction proves they share an owner. They were unlinkable before and will not be afterwards.
If you need to move funds from three addresses, send three transactions to three destinations.
Fresh destinations
Sending to an address already publicly tied to you connects the stealth address to your identity. Prefer a fresh destination, or an exchange deposit address generated for this deposit only.
Gas from the address itself, or a relayer
Funding a stealth address from your main wallet is the most common way privacy is lost, because the funding transaction is public and permanent. In order of preference:
- Gas that arrived with the payment
- A relayer
- Anything else
Give the analyser what it needs
analyseSpend can only flag a known address if you tell it which addresses are yours:
analyseSpend({
inputs, destination, gasSource,
knownIdentityAddresses: [myMainWallet, myExchangeDeposit], // ← this matters
});
Vary amounts and timing where it matters
Two things cryptography does not hide:
Amounts. Balances at individual addresses are visible. Receiving 137.42 and sending exactly 137.42 is a match anyone can make. Where it matters, split withdrawals or leave a remainder.
Timing. Moving funds minutes after they arrive makes the two transactions easy to associate. Waiting reduces this. Basis raises a note when a payment is under an hour old.
Neither is a flaw in stealth addresses; both are properties of a public ledger, and both are addressable by how you use it.
Balances and RPC choice
Scanning reveals nothing: every client downloads identical pages and matches locally.
Balance queries are different. Asking a node about specific addresses tells that node those addresses interest you. Ask about fifty in one burst and the operator learns your whole set.
Basis mitigates this — queries are shuffled uniformly, batched, and optionally jittered — but the strongest option is a node you control:
const balances = {
label: 'my own node',
isPrivate: true,
fetch: async (addresses) => { /* … */ },
};
console.log(balancePrivacyWarning(balances, addressCount));
Set isPrivate: false for public infrastructure and the wallet will say plainly what that exposes.
You can also skip balance fetching entirely. Announcement metadata already tells you what arrived, so a wallet can show that a payment landed without asking anyone anything.
Account custody
Random accounts
Keys are generated locally and exist only inside the encrypted vault. Nothing to phish. Back up the vault file and remember the passphrase — they are the only copy.
Signature-derived accounts
Keys derive from a wallet signature, so the account is reproducible on any device with that wallet and there is nothing to back up. The vault stores no private keys at all, only public keys and the index, so unlocking requires both the passphrase and the wallet.
Because the signature is the account, treat the derivation prompt with the same care as a seed phrase:
- Derive once at onboarding. A well-built wallet never asks again.
- Only ever approve it on a site you trust. Basis uses EIP-712 so your wallet displays a named domain and an explicit warning rather than opaque hex.
- The message is chain-bound: a signature for one chain will not unlock an account on another.
Passphrases
The vault is encrypted with a scrypt-derived key at 128 MB cost, which makes brute-forcing expensive. A long passphrase makes it impractical. Use a password manager.
Disclosure
Prefer a scoped disclosure to sharing a viewing key
A viewing key works on every announcement ever made and every one that ever will be. It cannot be limited to a date range, an asset, or a set of addresses. Sharing one for a single tax return grants permanent, unbounded visibility.
A disclosure covers exactly what you selected and nothing else, including nothing that arrives afterwards.
Always use a verifier binding
Ask the recipient for a one-time code and pass it as verifierBinding. This ties the statement to them alone — without it, an accountant handed proof of your holdings could reuse the same document to prove them to a lender.
createDisclosure requires it rather than defaulting, so this is difficult to get wrong.
Set an expiry
ttlMs marks a statement stale after a period. Verifiers reject expired disclosures.
Reserve viewing keys for continuous engagements
Ongoing audit or live LP reporting genuinely needs visibility into future payments. That is what exportViewingKey is for, and the scope statement travels with the key so whoever ends up holding the file understands what it is.
Integration checklist
Building on Basis:
- Attach a gas stipend to every payment you send
- Include transfer metadata in announcements
- Call
analyseSpendbefore signing, and treatsafe === falseas blocking - Populate
knownIdentityAddresseswith everything the user has told you is theirs - Call
recordSpendafter broadcasting, so the anonymity picture stays truthful - Persist the index encrypted — it is as sensitive as the keys
- Default to
isPrivate: falsefor public RPC and surfacebalancePrivacyWarning - Verify a disclosure's
verifierBindingmatches the code you issued - Read balances from the chain rather than trusting amounts in a disclosure
Quick reference
| Situation | Do this |
|---|---|
| Sending a payment | Attach gas, include metadata |
| Address has no gas | Use a relayer, never your own wallet |
| Moving from several addresses | One transaction each, different destinations |
| Choosing a destination | Use a fresh address |
| Checking balances | Prefer a node you control |
| Proving holdings | Scoped disclosure with a verifier binding |
| Ongoing audit | Viewing key, deliberately |
| Just received a payment | Wait before moving it, where timing matters |
