PRIVATE / BY DEFAULT

BASIS / TECHNICAL DOCUMENTATION

Private rails.
Public standard.

Build private receiving, unified portfolios, safe spending, and selective disclosure for tokenized real-world assets.

8 CHAPTERS5 PACKAGESERC-5564ERC-6538
00

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
01

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

02

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

WORKED EXAMPLE / PRIVATE PAYMENTERC-5564

ONE PUBLIC HANDLE / ONE NEW ADDRESS PER PAYMENT

01 / RECIPIENTPermanent meta-addressspendPub + viewPub
02 / SENDEROne-time derivationECDH → shared secret → tweak
03 / SETTLEMENTFresh stealth address0x9af2…4373 + announcement

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

  1. Generates a one-time ephemeral keypair, used for this payment only
  2. Performs ECDH between the ephemeral private key and your viewing public key, producing a shared secret only the two of you can compute
  3. Hashes that secret into a tweak
  4. Adds the tweak to your spending public key: stealthPub = spendPub + tweak·G
  5. Derives an Ethereum address from the result and sends the funds there
  6. Publishes an announcement containing the ephemeral public key and a one-byte view tag
  7. 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

WORKED EXAMPLE / LOCAL SCANNINGNO IDENTIFIER SENT
page 041 / tag 2aDROP
page 041 / tag 85KEEP
page 041 / tag d1DROP
page 041 / tag 09DROP
PUBLIC PAGESLOCAL SCANNERviewing key stays here
255 / 256foreign announcements rejected cheaply by view tag
1 MATCHtweak retained; no spendable private key stored

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
03

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

WORKED EXAMPLE / PRIVACY ANALYSISBLOCK BEFORE SIGNING

WALLET STATE / UNLINKED

0xB0f8…120 NVDAx
0x2C6d…340 NVDAx
0xE626…75 NVDAx
Sweep all → 0x2A9f…6F70
CRITICAL / GAS LINKAGEMain-wallet gas publicly connects the payment to your identity.
CRITICAL / CONSOLIDATIONThree inputs in one transaction become one public cluster.
CRITICAL / KNOWN DESTINATIONThe destination is already publicly associated with you.
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

WORKED EXAMPLE / SAFEST PLANANALYSIS: SAFE
01 / INPUTChoose one addressNo multi-input consolidation.
02 / GASUse included gas or relayNo funding trail from a known wallet.
03 / OUTPUTUse a fresh destinationNo prior identity association.
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

WORKED EXAMPLE / SELECTIVE DISCLOSUREBOUND TO ONE VERIFIER

PRIVATE PORTFOLIO / HOLDER SELECTS

0x9af2…4373NVDAx
0x64c1…29baT-BILLx
0x7d02…a114AAPLx
0xb818…5f90CREDITx

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.

04

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

05

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:

  1. Send exactly 0.01 ETH to 0x3fab184622dc19b6109349b94811493bf2a45362
  2. Broadcast the chain-agnostic presigned transaction (see basis-deploy/README.md)
  3. Re-run deploy.mjs

Many Orbit chains pre-deploy the proxy in genesis, so check first.


The announcement service (indexer)

WORKED EXAMPLE / ANNOUNCEMENT PATHIDENTICAL DATA FOR EVERY CLIENT
01Canonical announcerEmits public ERC-5564 events.
02IndexerBuilds fixed-width immutable pages.
03CDN / proxyServes the same sealed bytes to everyone.
04Local walletDownloads all pages and matches privately.

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.

06

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_KDF uses 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:

  1. Recompute the body digest and recover the signer; it must match the spending public key inside metaAddress
  2. For each item, recover the control proof signer; it must equal the claimed stealth address
  3. Confirm verifierBinding matches the code the verifier issued
  4. 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.

07

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:

  1. Gas that arrived with the payment
  2. A relayer
  3. 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 analyseSpend before signing, and treat safe === false as blocking
  • Populate knownIdentityAddresses with everything the user has told you is theirs
  • Call recordSpend after broadcasting, so the anonymity picture stays truthful
  • Persist the index encrypted — it is as sensitive as the keys
  • Default to isPrivate: false for public RPC and surface balancePrivacyWarning
  • Verify a disclosure's verifierBinding matches 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