# Sign blocks from a server safely

- Page: https://nano133.com/guides/sign-blocks-safely
- Stage: Alpha · last checked 2026-10-25
- Advanced · about 40 minutes
- Agent pack: https://nano133.com/guides/sign-blocks-safely/SPEC.md · https://nano133.com/guides/sign-blocks-safely/PROMPT.md · https://nano133.com/guides/sign-blocks-safely/test.mjs
- Use at your own risk, for education. Real Nano moves: keep amounts small.

> No forks, no double sends, and many wallets from one secret. A lock with a fence, and a block that is recorded before it is published.

A server that holds the key of a wallet signs blocks on its own: a sweep, a refund, a payout. Three things can go wrong with that, and each one costs real Nano. A send can go out twice. Two blocks can be signed on the same frontier, which is a fork. And a crash can cut a send in half, so that nobody knows if it happened. This guide shows the rules that stop all three. They are the rules that the nano133.com till and Late Payment Return run on.

## What goes wrong

- **A double send.** Most code publishes a send and records it after. If the answer is lost on its way back from the node, or the server stops between the two, the job looks unsent. The next run sends it again from the balance of the wallet.
- **A fork.** Two runs at the same moment both read the wallet's frontier (its last block) and both sign a block on it. The network keeps one block. The other block, and every block built on it, is rolled back. This is why nano133.com's till never receives a block that is not confirmed yet: a fork would roll back its own chain, and every send after it.
- **A run that outlives its lock.** A run that is slow, or that waits on a slow node, can still be running after another run has taken over the wallet. If it signs or publishes then, it forks the wallet.

> **Warning:** **The cost: your server holds keys.** A key on a server is a hot wallet: anyone who gets into the server can take what is in it. Keep little in a server wallet, keep the secret in your secret manager, and use your own Nano node: its answers decide whether money moves again. Test with throwaway secrets and a mock node, and with tiny amounts after that.

## What you need

- **Your own Nano node** with its RPC URL. It must allow `account_info`, `account_history`, `block_info`, `work_generate` and `process`.
- **A database with transactions and a unique key.** The example uses SQLite (`node:sqlite`), which has both. nano133.com's own code uses Firestore, and its game starter uses D1.
- **A secret** of 32 random bytes in hex, from your secret manager.
- **Node.js 22 or newer** and two packages to run the example: `nanocurrency-web` (2.0.0) and `@noble/hashes` (2.2.0). The checks ran on Node.js 22.21.1 and 24.13.0. `node:sqlite` prints a warning that it is experimental.

## Step 1: Many wallets from one secret

**Do:** Make many wallets from one secret, and store only the index.

```
const seed = createHmac("sha256", secret).update(LABEL).digest("hex").toUpperCase();
const account = wallet.legacyAccounts(seed, index, index)[0];   // .address is public; .privateKey never leaves this function
```

One secret makes many wallets. Your secret and a label make a seed (HMAC-SHA-256), and wallet number *index* is account *index* of that seed. The same secret, label and index always give the same wallet. You store the index, never a key.

- **One label for each purpose.** A label such as `my-shop wallets v1` names what the seed is for. A second job (the checkouts, the payouts) gets its own label, or its own secret. nano133.com keeps the till on one secret and its checkout addresses on another.
- **Never change the secret or the label after money has come in.** The money stays in the older wallets, and only the old secret and label sign for them. If the secret is lost, nobody can sign for those wallets.
- **Take each index from an atomic counter** in your database, and refuse an index of 1,000,000 or more.
- **A key exists only inside the function that signs.** Never log it, return it or put it in an error.

> **Rule:** Never change the secret or the label of a seed once money has come in. Never print, log, store or return a seed or a private key.

**Check:** run `node safe-send.mjs address 0` twice: the same address. Run it with index `1`: another address. Run it with another secret: another address. An index of `1000000`, or a secret shorter than 32 characters, is refused.

## Step 2: One run at a time for each wallet

**Do:** Take one lock for each wallet, and prove it at every write.

```
INSERT INTO locks (account, owner, until) VALUES (?, ?, ?)
ON CONFLICT (account) DO UPDATE SET owner = excluded.owner, until = excluded.until
WHERE locks.until <= ?            -- takes over only a lock whose time has passed
-- changes = 1: this run holds the lock.  changes = 0: another run does. Stop, and try later.
```

Only one run may sign blocks for a wallet at a time. Make a lock for each wallet, and make **every** piece of code that signs for the wallet use it: the jobs, the sweep and the returns. nano133.com's till has one lock, and its jobs, its late returns and its sweep never run at once.

The lock is a row: the wallet, the owner (a random id of this run) and the time until which it holds. Taking it is one statement, so two runs never both get it (the lines above).

```
UPDATE locks SET until = ? WHERE account = ? AND owner = ? AND until > ?
-- changes = 0: the lock was lost. Throw, and do nothing more: no record, no publish.
```

A lock that only says "taken" is not enough, because a run can outlive it. So every write that records money has a **fence**: it runs in one transaction that first proves, in one statement, that this run still holds the lock and has time left. It renews the lock in the same statement (the lines above).

A run that outlived its lock cannot record, and so it cannot publish. Give a lock about a minute, and renew it before every step that is slow.

> **Rule:** Every piece of code that signs a block for a wallet takes that wallet's lock first. Every write that records money, and every publish, proves the lock in the same transaction. A run that lost its lock stops at once.

**Check:** start a send that waits while it holds the lock, and start a second send for the same wallet: the second answers `busy` (exit code 4) and does nothing. Start a send whose lock runs out while it waits, and let another run take the wallet: the slow run answers `the wallet lock was lost` (exit code 5), publishes nothing, and the wallet does not fork.

## Step 3: Read the wallet, then build the block

**Do:** Read the wallet from your node, then build the block from that.

```
{ "action": "account_info", "account": "<the wallet>", "representative": "true" }
// frontier, balance, representative.  "Account not found": the wallet is not opened yet (balance 0, no frontier).
```

When you hold the lock, ask your node for the wallet's state and build the block from that answer, never from memory, a cache or the last run (the lines above).

```
{ "action": "work_generate", "hash": "<the frontier>", "difficulty": "fffffff800000000" }   // a send; a receive uses fffffe0000000000
const signed = block.send({ walletBalanceRaw, fromAddress, toAddress, representativeAddress, frontier, amountRaw, work }, privateKey);
```

A **send** takes the balance, the frontier and the representative from that answer. Your node makes the proof of work, and the library signs (the lines above).

A **receive** takes in one payment, by its hash. Receive only a block that your node says is a confirmed send to this wallet, and never "everything that waits": a wrong amount that waits at the address is not yours to take in. If the answer to a receive is `Unreceivable`, an earlier run took it in. A receive is safe to repeat, because the ledger takes a send only once.

> **Rule:** Receive only a block that the node reports as a confirmed send to this wallet, one block at a time. Never build a block from a frontier or a balance that you did not just read.

**Check:** run `NANO_RPC=… node safe-send.mjs state <address>`: it prints the frontier, the balance in raw and the representative that your node reports. A block that is not confirmed is refused: `not confirmed yet`. A block that was received is not received twice.

## Step 4: Record the block before you publish it

**Do:** Sign the block, record it with its hash, then publish it.

```
const hash = stateBlockHash(signed);                         // before anything is published
lock.record(() => INSERT INTO sends (job, …, hash, root, block) VALUES (…, hash, frontier, signed));
await publish(signed);                                       // { "action": "process", "subtype": "send", … }
lock.record(() => UPDATE sends SET status = 'done', sent_hash = ? WHERE job = ?);
```

This is the step that stops the double send. Do a send in two phases, both under the lock:

1. **Sign the block and work out its hash,** and record the hash, the frontier the block builds on (its *root*) and the whole signed block on the job. The record is one fenced transaction.
2. **Publish the block, then mark the job done,** again under the fence.

The hash of a state block comes from the block alone: BLAKE2b-256 of a preamble, the account, the previous block, the representative, the balance and the link. So you know the hash before the node does, and you can ask the ledger about it later.

A job has one id, and the id names one send: a wallet, an address and an amount. A second request with the same id and another address or amount is refused, not sent.

> **Rule:** Sign, then record the block and its hash, then publish. Never publish a block that is not recorded. One job id means one send: refuse a job id that comes back with another wallet, address or amount.

**Check:** send for a job: `sent: block <hash>`, and the hash is the one that was recorded. Send for the same job again: `already sent: block <hash>`, and the ledger holds one send.

## Step 5: Run it again after a stop

**Do:** After a stop, look at the ledger first, then decide.

```
if (await onLedger(row.hash))          return done(row.hash);                        // 1
if (row.root === state.frontier)       { publish(row.block); return done(row.hash); } // 2
const ours = await successorSend(account, row.root, to, balanceAfter);               // 3: account_history, head = root, reverse
if (ours)                              return done(ours);
forget(row);                                                                         // 4: then sign a new block
```

A server can stop at any point: a crash, a deploy, a lost answer. A job that has a recorded block is **never sent anew** until the ledger shows that the recorded block can never land. Look at the ledger first, then decide:

1. **The block is on the ledger** (`block_info` finds its hash): the job is done. Mark it so. Do not send.
2. **It is not there, and the wallet's frontier is still the block's root:** publish the same block again. A block can land only once. If the node answers `Old block`, check the ledger again: it has the block, and the job is done.
3. **The wallet moved on without it:** look at the block that followed the recorded root. If it is a send to this address that left this balance, it is ours: the job is done. Ask for the chain from the recorded root forward (`account_history` with `head` and `reverse`), not for the newest blocks. On a busy wallet the recorded block can be far back: the wallet that the real-node check reads has more than 10,000 blocks.
4. **Nothing of ours follows the root:** the recorded block can never be valid. Forget it and make a new block from the wallet's state now.

Any other answer from the node, such as an error or a timeout, means "unknown". Then do nothing and try again later. Only "not found" lets you go on.

> **Rule:** After a stop, never sign a new block for a job that has a recorded block until the ledger answers that the recorded block is not there and cannot land. An answer that is not "not found" means wait.

**Check:** stop a send after it recorded the block and before it published: the ledger holds nothing, and the next run publishes the same block (the recorded hash). Stop one after the node took the block: the next run says `already sent` and the ledger still holds one send. Stop one, let another job move the wallet, and run the first again: it makes a new block, and each job sent once.

## Step 6: Send only what your code says

**Do:** Send only what your code says: the destination comes from your code.

- **The destination is a value in your code,** or in your deploy settings. Never read the address of an automatic send from a request, or from a database row that a visitor can change. nano133.com's sweep sends to one constant address, and its till sends only to a short list of payees.
- **Refuse what cannot be right:** an address that is not valid (shape and checksum), the wallet itself, an amount that is not a whole number of raw, an amount above the balance.
- **Keep little in a server wallet.** Move what you can to a wallet whose key is not on a server.
- **A test never sends money.** Run your tests on a mock node, as `node test.mjs` does here.

> **Rule:** The destination of an automatic send comes from your code or your deploy settings, never from a request or from a row that someone else can write. A test never sends real Nano.

**Check:** `send` refuses an address that is not valid, the wallet itself, an amount above the balance and an amount that is not whole raw. A job id that comes back with another amount is refused.

## Run the whole thing

The example does steps 1 to 6 for a wallet of your secret. It needs a node, and it sends: **use a mock node or a tiny amount.** `node test.mjs` runs all of it against a fake node on your computer and sends nothing.

```
npm install nanocurrency-web @noble/hashes
export WALLET_SECRET=$(openssl rand -hex 32)       # a throwaway secret: write it down
node safe-send.mjs address 0                        # the address of wallet 0 (it needs no node)
```

```
export NANO_RPC=https://your-node.example/rpc
node safe-send.mjs receive ./wallets.db 0 <hash of a confirmed send to that address>
node safe-send.mjs send ./wallets.db 0 order-1 <to address> <amount in raw>
node safe-send.mjs job ./wallets.db order-1
```

File `safe-send.mjs` (https://nano133.com/guides/sign-blocks-safely/code/safe-send.mjs):

```js
// Sign blocks for a Nano account from a server, with no fork and no double send.
// From the nano133.com guide "Sign blocks from a server safely". MIT license.
// Node.js 22.13 or newer (it uses node:sqlite) and two packages (npm install nanocurrency-web @noble/hashes).
//
//   export WALLET_SECRET=$(openssl rand -hex 32)       # a throwaway secret for a test
//   node safe-send.mjs address <index>
//       prints the address of wallet number <index> of your secret (it needs no node).
//
//   NANO_RPC=… node safe-send.mjs receive <database> <index> <send block hash>
//       receives that one confirmed send into wallet <index>, under the wallet's lock.
//
//   NANO_RPC=… node safe-send.mjs send <database> <index> <job> <to address> <amount in raw>
//       sends <amount> from wallet <index> to <to address>, once for this <job>, however often you run it.
//
//   node safe-send.mjs job <database> <job>
//       prints what the database knows about a job.
//
//   NANO_RPC=… node safe-send.mjs state <address>
//       prints the frontier, the balance (raw) and the representative that your node reports for an address. It only reads.
//
//   NANO_RPC=… node safe-send.mjs successor <address> <block hash> <to address> <balance after the send, in raw>
//       prints the hash of the send that followed that block on the address's chain, when it went to <to> and left that balance.
//
// Two environment variables exist for tests only: GUIDE_TEST_CRASH=after-record (or after-publish) stops the run at that
// point, and GUIDE_TEST_PAUSE_MS=<ms> waits there. LOCK_TTL_MS changes how long a lock lasts (default 60000).
// In your server the database is the one you already have (steps 3 to 5), the secret comes from your secret manager
// (step 1), and a seed or a key is never printed or logged. node:sqlite prints a warning that it is experimental.

import { createHmac, randomBytes } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import nano from "nanocurrency-web";
import { blake2b } from "@noble/hashes/blake2.js";

const { wallet, block, tools } = nano;
const LABEL = "my-shop wallets v1"; // one label for each purpose. Never change it, or the secret, after the first wallet is used.
const SEND_WORK = "fffffff800000000";
const RECEIVE_WORK = "fffffe0000000000";
const ZERO = "0".repeat(64);

async function rpc(body) {
  const res = await fetch(process.env.NANO_RPC, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(25_000) });
  const json = await res.json();
  if (json.error) throw new Error(`${body.action}: ${json.error}`);
  return json;
}
const notFound = (e) => /not found/i.test(String(e?.message ?? e));

// ---- Step 1. Many wallets from one secret ----------------------------------------------------------------------

/** Wallet number `index`: one account of the seed that your secret and your label make. */
export function accountOf(secret, index) {
  if (typeof secret !== "string" || secret.length < 32) throw new Error("Set WALLET_SECRET to at least 32 characters (openssl rand -hex 32).");
  if (!Number.isInteger(index) || index < 0 || index >= 1_000_000) throw new Error("The wallet index is a whole number from 0 to 999999.");
  const seed = createHmac("sha256", secret).update(LABEL).digest("hex").toUpperCase();
  return wallet.legacyAccounts(seed, index, index)[0]; // .address is public. Never show or log .privateKey
}

// ---- Step 2. One run at a time for each wallet: a lock with a fence ----------------------------------------------

export class LockLost extends Error {
  constructor() {
    super("the wallet lock was lost");
  }
}

export function openDatabase(path) {
  const db = new DatabaseSync(path);
  db.exec(`PRAGMA busy_timeout = 5000;
    CREATE TABLE IF NOT EXISTS locks (account TEXT PRIMARY KEY, owner TEXT NOT NULL, until INTEGER NOT NULL);
    CREATE TABLE IF NOT EXISTS sends (
      job TEXT PRIMARY KEY, account TEXT NOT NULL, to_address TEXT NOT NULL, amount TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'new',          -- new | signed | done
      hash TEXT, root TEXT, block TEXT,            -- the signed block, its hash, and the frontier it builds on
      sent_hash TEXT                               -- the send that is on the ledger, when status is done
    );`);
  return db;
}

/**
 * The lock of one wallet. `take` is one statement: it makes the row, or takes it over once its time has passed.
 * `record` is the fence: it runs a write in a transaction that first proves, in one statement, that this run still
 * holds the lock and has not run out of time. A run that outlived its lock cannot write, and so cannot send.
 */
export function lockOf(db, account, ttlMs = Number(process.env.LOCK_TTL_MS) || 60_000) {
  const owner = randomBytes(8).toString("hex");
  const mine = () => {
    const r = db.prepare("UPDATE locks SET until = ? WHERE account = ? AND owner = ? AND until > ?").run(Date.now() + ttlMs, account, owner, Date.now());
    if (Number(r.changes) !== 1) throw new LockLost();
  };
  return {
    owner,
    take() {
      const r = db
        .prepare("INSERT INTO locks (account, owner, until) VALUES (?, ?, ?) ON CONFLICT (account) DO UPDATE SET owner = excluded.owner, until = excluded.until WHERE locks.until <= ?")
        .run(account, owner, Date.now() + ttlMs, Date.now());
      return Number(r.changes) === 1;
    },
    record(write = () => {}) {
      db.exec("BEGIN IMMEDIATE");
      try {
        mine();
        const result = write();
        db.exec("COMMIT");
        return result;
      } catch (e) {
        db.exec("ROLLBACK");
        throw e;
      }
    },
    release() {
      db.prepare("DELETE FROM locks WHERE account = ? AND owner = ?").run(account, owner);
    },
  };
}

// ---- Step 3. Read the wallet's state, then build the block ------------------------------------------------------

/** What the node says about the wallet right now: its frontier, balance and representative. Read it after you hold the lock. */
export async function stateOf(address) {
  try {
    const i = await rpc({ action: "account_info", account: address, representative: "true" });
    return { frontier: i.frontier, balance: BigInt(i.balance), representative: i.representative };
  } catch (e) {
    if (notFound(e)) return { frontier: null, balance: 0n, representative: null }; // not opened yet
    throw e;
  }
}

const work = async (root, difficulty) => (await rpc({ action: "work_generate", hash: root, difficulty })).work;
const hex = (h) => Uint8Array.from(h.match(/../g).map((b) => parseInt(b, 16)));
const key = (address) => hex(tools.addressToPublicKey(address));

/** The hash of a state block: BLAKE2b-256 of the preamble (6), account, previous, representative, balance and link. */
export function stateBlockHash(b) {
  const parts = [hex("0".repeat(63) + "6"), key(b.account), hex(b.previous), key(b.representative), hex(BigInt(b.balance).toString(16).padStart(32, "0")), hex(b.link)];
  const bytes = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
  let at = 0;
  for (const p of parts) (bytes.set(p, at), (at += p.length));
  return Buffer.from(blake2b(bytes, { dkLen: 32 })).toString("hex").toUpperCase();
}

// ---- Step 4. Send once: record the block before you publish it ---------------------------------------------------

async function onLedger(hash) {
  try {
    await rpc({ action: "block_info", hash, json_block: "true" });
    return true;
  } catch (e) {
    if (notFound(e)) return false; // the node says it does not know this block
    throw e; // any other answer: we do not know, so do not send
  }
}

/**
 * The send that followed `root` on the wallet's chain, if it went to `to` and left `balance`: an earlier run's send, found by what it did.
 * It asks for the chain from `root` forward (head and reverse), so it does not matter how many blocks came after.
 */
export async function successorSend(account, root, to, balance) {
  const h = await rpc({ action: "account_history", account, count: "2", raw: "true", head: root, reverse: "true" });
  const next = (h.history || []).find((x) => String(x.previous ?? "").toUpperCase() === root.toUpperCase());
  if (!next || (next.subtype ?? next.type) !== "send") return null;
  return String(next.link ?? "").toUpperCase() === tools.addressToPublicKey(to).toUpperCase() && BigInt(next.balance ?? "-1") === balance ? String(next.hash).toUpperCase() : null;
}

const test = (point) => {
  if (process.env.GUIDE_TEST_CRASH === point) process.exit(3); // tests only: the run dies here, as a server can
};
const pause = (ms) => new Promise((ok) => setTimeout(ok, ms));

/**
 * Sends `amount` from the wallet to `to` for one job, at most once ever. Call it while you hold the lock.
 * `state` is the wallet's state now. Answers { hash, fresh }: `fresh` is false when an earlier run's send was found.
 */
export async function sendOnce({ db, lock, account, privateKey, job, to, amount, state }) {
  let row = db.prepare("SELECT * FROM sends WHERE job = ?").get(job);
  if (row && (row.to_address !== to || row.amount !== amount.toString() || row.account !== account)) throw new Error(`Job ${job} was made for another wallet, address or amount.`);
  if (row?.status === "done") return { hash: row.sent_hash, fresh: false };
  const done = (hash) => (lock.record(() => db.prepare("UPDATE sends SET status = 'done', sent_hash = ? WHERE job = ?").run(hash, job)), { hash, fresh: false });

  if (row?.status === "signed") {
    // An earlier run signed this block and may have lost the answer. Look at the ledger before you do anything else.
    if (await onLedger(row.hash)) return done(row.hash);
    if (row.root === state.frontier) {
      // It is not there, and nothing was built on the wallet since: publish the same block again. It can land only once.
      lock.record();
      let fresh = true;
      try {
        await rpc({ action: "process", json_block: "true", subtype: "send", block: JSON.parse(row.block) });
      } catch (e) {
        if (!(await onLedger(row.hash))) throw e; // the node had it after all ("Old block"): then it is done
        fresh = false;
      }
      return { ...done(row.hash), fresh };
    }
    // The wallet moved on without this block. If what followed the recorded frontier is this very send, it is ours.
    const ours = await successorSend(account, row.root, to, BigInt(JSON.parse(row.block).balance));
    if (ours) return done(ours);
    // Nothing of ours follows it: that block can never be valid. Forget it and make a new one.
    lock.record(() => db.prepare("UPDATE sends SET status = 'new', hash = NULL, root = NULL, block = NULL WHERE job = ?").run(job));
    row = undefined;
  }

  if (state.balance < amount) throw new Error("Not enough in the wallet for this send.");
  const signed = block.send(
    { walletBalanceRaw: state.balance.toString(), fromAddress: account, toAddress: to, representativeAddress: state.representative, frontier: state.frontier, amountRaw: amount.toString(), work: await work(state.frontier, SEND_WORK) },
    privateKey,
  );
  const hash = stateBlockHash(signed);
  // Phase 1: record the block before it exists anywhere else.
  lock.record(() =>
    db
      .prepare("INSERT INTO sends (job, account, to_address, amount, status, hash, root, block) VALUES (?, ?, ?, ?, 'signed', ?, ?, ?) ON CONFLICT (job) DO UPDATE SET status = 'signed', hash = excluded.hash, root = excluded.root, block = excluded.block")
      .run(job, account, to, amount.toString(), hash, state.frontier, JSON.stringify(signed)),
  );
  test("after-record");
  if (process.env.GUIDE_TEST_PAUSE_MS) await pause(Number(process.env.GUIDE_TEST_PAUSE_MS));
  // Phase 2: prove the lock again, publish, then mark the job done. If either is lost, the next run finds the block by its hash.
  lock.record();
  const published = String((await rpc({ action: "process", json_block: "true", subtype: "send", block: signed })).hash).toUpperCase();
  test("after-publish");
  return { ...done(published), fresh: true };
}

// ---- Step 5. Receive one confirmed block ------------------------------------------------------------------------

/** Receives one send into the wallet. A receive is safe to repeat: the ledger takes a send only once ("Unreceivable"). */
export async function receiveOne({ lock, account, privateKey, hash, state }) {
  const paid = await rpc({ action: "block_info", hash, json_block: "true" });
  if ((paid.subtype ?? paid.contents?.subtype) !== "send" || paid.contents?.link_as_account !== account) throw new Error("That block is not a send to this wallet.");
  if (paid.confirmed !== "true") throw new Error("That block is not confirmed yet. Never receive a block that is not confirmed.");
  const rep = state.representative ?? paid.contents.representative ?? paid.block_account;
  const received = block.receive(
    { walletBalanceRaw: state.balance.toString(), toAddress: account, representativeAddress: rep, frontier: state.frontier ?? ZERO, transactionHash: hash, amountRaw: paid.amount, work: await work(state.frontier ?? tools.addressToPublicKey(account), RECEIVE_WORK) },
    privateKey,
  );
  lock.record();
  try {
    return String((await rpc({ action: "process", json_block: "true", subtype: state.frontier ? "receive" : "open", block: received })).hash).toUpperCase();
  } catch (e) {
    if (/unreceivable/i.test(String(e?.message ?? e))) return null; // an earlier run received it
    throw e;
  }
}

// ---- The command line -------------------------------------------------------------------------------------------

/** Takes the wallet's lock, reads its state, runs `fn`, and always lets the lock go. Answers { busy: true } when another run holds it. */
async function withWallet(db, address, fn) {
  const lock = lockOf(db, address);
  if (!lock.take()) return { busy: true };
  try {
    return { value: await fn(lock, await stateOf(address)) };
  } finally {
    lock.release();
  }
}

async function main(args) {
  const [command, ...rest] = args;
  if (command === "address") return console.log(accountOf(process.env.WALLET_SECRET, Number(rest[0])).address);
  if (command === "job") {
    const row = openDatabase(rest[0]).prepare("SELECT job, status, hash, root, sent_hash FROM sends WHERE job = ?").get(rest[1]);
    return console.log(row ? JSON.stringify(row) : "no such job");
  }
  if (!process.env.NANO_RPC) throw new Error("Set NANO_RPC to your node's RPC URL.");
  if (command === "state") {
    const s = await stateOf(rest[0]);
    return console.log(JSON.stringify({ frontier: s.frontier, balance: String(s.balance), representative: s.representative }));
  }
  if (command === "successor") {
    const [account, root, to, balance] = rest;
    return console.log((await successorSend(account, root, to, BigInt(balance))) ?? "none");
  }
  const [path, index] = rest;
  const acct = accountOf(process.env.WALLET_SECRET, Number(index));
  const db = openDatabase(path);
  const busy = () => (console.error("busy: another run holds this wallet's lock"), process.exit(4));
  if (command === "receive") {
    const r = await withWallet(db, acct.address, (lock, state) => receiveOne({ lock, account: acct.address, privateKey: acct.privateKey, hash: rest[2].toUpperCase(), state }));
    if (r.busy) busy();
    return console.log(r.value ? `received: block ${r.value}` : "received before");
  }
  if (command === "send") {
    const [, , job, to, amount] = rest;
    if (!tools.validateAddress(to)) throw new Error("That is not a valid Nano address.");
    if (to === acct.address) throw new Error("The destination is the wallet itself.");
    if (!/^[1-9][0-9]*$/.test(amount ?? "")) throw new Error("The amount is a whole number of raw.");
    const r = await withWallet(db, acct.address, (lock, state) => sendOnce({ db, lock, account: acct.address, privateKey: acct.privateKey, job, to, amount: BigInt(amount), state }));
    if (r.busy) busy();
    return console.log(`${r.value.fresh ? "sent" : "already sent"}: block ${r.value.hash}`);
  }
  throw new Error("Commands: address, receive, send, job, state, successor. Read the top of this file.");
}

// Run the command line only when this file is started by itself, not when your server imports it.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main(process.argv.slice(2)).catch((e) => {
    console.error(e instanceof LockLost ? "lost: the wallet lock was lost; nothing was sent after that" : (e?.message ?? e));
    process.exit(e instanceof LockLost ? 5 : 1);
  });
}
```

> **Warning:** **Write the secret down before you pay.** It lives only in that terminal. Whoever has it can move the money at its wallets, and without it nobody can. **Checked so far:** the whole flow against a local copy of the ledger (real signatures, real block hashes, real transactions and concurrent runs), and the reads of steps 3 and 5 against a real node (25 October 2026: a wallet's state, an unopened address, and the send that followed a block). **Not checked:** a send on the real network. Test it with a tiny amount first.

## Or use the kit

[Late Payment Return](https://nano133.com/build/late-payment-return) packages these rules for sending money back (`sendOnce`, a fenced lock and stores for Firestore or your own database), and [Nano Checkout](https://nano133.com/build/nano-checkout) uses them for its sweep. The logic is the same as in this guide. The guide [Send money back exactly once](https://nano133.com/guides/send-money-back-exactly-once) builds a whole return flow on top of the file above.

## What breaks, and what it costs

| Failure | Loss in Nano | Check |
| --- | --- | --- |
| The secret or the label changes after money came in, or the secret is lost | The money stays in the older wallets. Only the old secret and label sign for them | Step 1: never change either |
| A secret or a key is logged, printed or sent to a browser | Everything in every wallet it can sign for | Steps 1 and 6: a key lives inside the signing function only; keep little in a wallet |
| Two runs sign on one frontier (a fork) | The block that loses, and every block built on it, is rolled back: a send in it does not arrive | Step 2: one lock for each wallet, used by all code that signs |
| A run outlives its lock and publishes | A fork, or a second send of the same payment | Step 2: the fence proves the lock before every record and every publish |
| A send is published, the answer is lost, and the job runs again | The same payment goes out twice | Steps 4 and 5: record the block first, and ask the ledger before you send anew |
| The ledger is asked and the node gives an error, and the run sends a new block anyway | The same payment goes out twice, if the first block was on the ledger | Step 5: only "not found" lets you go on |
| A block that is not confirmed is received | The wallet's chain is rolled back with every send after it | Step 3: receive only confirmed blocks |
| A block is built from an old frontier or balance | None: the node refuses it, and the job waits | Step 3: read the wallet's state after you hold the lock |
| A job id comes back with another address or amount | The wrong payment goes out | Step 4: one job id names one send; refuse the rest |
| The destination is read from a request or a row | The money goes where a visitor chose | Step 6: the destination is in your code |

## Sources

- [Late Payment Return](https://nano133.com/build/late-payment-return) · the same rules as a package, from the nano133 Kit
- [Nano Checkout](https://nano133.com/build/nano-checkout) · its sweep sends through the same rules
- [Accept Nano payments without a processor](https://nano133.com/guides/accept-nano-payments) · the sweep of step 7 there uses a wallet of this guide
- [Nano node RPC reference](https://docs.nano.org/commands/rpc-protocol/) · account_info, account_history, block_info, work_generate, process
- [nanocurrency-web on GitHub](https://github.com/numsu/nanocurrency-web-js) · seeds, accounts and signed blocks (MIT)
- [noble-hashes on GitHub](https://github.com/paulmillr/noble-hashes) · BLAKE2b, for the block hash (MIT)
- [nano.org](https://nano.org/) · the Nano Foundation

Code: MIT license. Written by nano133.com from code that runs on this site.
