# Send money back exactly once

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

> Find a payment that nothing claimed, decide that it must go back, record that, and send it back once, even if the server stops. The late-return pattern.

Some payments arrive that nothing wants. A buyer pays after the checkout closed, or sends the wrong amount, or pays twice. The money sits in your wallet and the buyer waits. This guide is about what to do with such a payment: find it, decide that it must go back, record that decision, and send exactly that amount to the address that paid, once.

## How this guide differs from "Sign blocks from a server safely"

That guide is about **one send**: how a server signs a block so that it goes out once, with a lock and a block that is recorded before it is published. This guide is about **the decision above it**: which payment must go back, to whom, how often, and how to record that so that no checkout can also take it. The two fit together. The code here uses the file of [Sign blocks from a server safely](https://nano133.com/guides/sign-blocks-safely), `safe-send.mjs`, for the receive and the send. It does not repeat the lock or the recorded block. Read that guide for them.

> **Warning:** **This sends real money from a wallet whose key is on your server.** Keep only small amounts in that wallet, keep the key in your secret manager, and use your own node. A return goes to the address that paid. If that address is an exchange, the money goes to the exchange's address, not to the person's account there, and may be lost to the person. Test with a mock node and throwaway secrets, and then with tiny amounts.

## What you need

- **A wallet whose key your server holds,** the one that your checkouts pay into. If your site pays into a wallet that your server has no key for (a phone wallet, an exchange), returns are not possible: route payments through a server wallet first.
- **A record of every payment that a checkout claimed,** by the send block's hash. [Accept Nano payments without a processor](https://nano133.com/guides/accept-nano-payments) records them in its step 6.
- **Your own Nano node.** It must allow `receivable`, `block_info`, `account_info`, `account_history`, `work_generate` and `process`.
- **A database with transactions.** The example uses SQLite (`node:sqlite`).
- **Node.js 22 or newer** and two packages: `nanocurrency-web` (2.0.0) and `@noble/hashes` (2.2.0). The checks ran on Node.js 22.21.1 and 24.13.0.
- **The two files in this guide:** `return-once.mjs`, and `safe-send.mjs` beside it.

## Step 1: Find the payments that wait

**Do:** Ask your node for the payments that wait. The scan only reads.

```
{ "action": "receivable", "account": "<the wallet>", "count": "50", "threshold": "<the smallest payment that goes back>", "source": "true" }
```

A payment that nobody received waits at the wallet's address. Ask your node for them. The `threshold` keeps out everything below the smallest payment that you return (the lines above).

Run this from a timer every few minutes, never from a visitor's request. The scan only reads: it receives nothing and sends nothing. A payment that the list shows is only a candidate. Step 2 decides.

**Check:** pay a mock wallet 0.002 XNO, three hours old. `node return-once.mjs candidates <the wallet's address>` prints it, and writes nothing. `node return-once.mjs scan ./returns.db 0` prints `recorded 1, returned 0`, the ledger holds no send, and the wallet received nothing.

## Step 2: Decide that it must go back

**Do:** Decide from the payment's own block whether it must go back.

```
const info = await rpc({ action: "block_info", hash, json_block: "true" }).catch(() => null);
if (!info || info.confirmed !== "true") return "stays";                                  // when the node errs: skip it this run
// a send to this wallet, of the amount the list gave, from a sender that may get a return, old enough,
// and that no open checkout can still claim: otherwise it stays
```

For each candidate, ask the node for **the block itself** (`block_info`), and check every rule. The rules are the defaults of the kit's Late Payment Return package, which nano133.com runs for its own till:

| The payment goes back only when | The rule |
| --- | --- |
| it is a **confirmed send to this wallet**, of exactly the amount that the list gave | always |
| it is at least **0.001 XNO** | `minRaw`, the package's default. A smaller payment stays: it is not worth two blocks of work. Set `minRaw` lower to return smaller ones |
| the node first saw it at least **2 hours** ago | `minAgeSec`, longer than the grace of any checkout |
| **no checkout used it,** and no open checkout can still claim it | `isUsed` and `isClaimable`: your records answer |
| it is **not from one of your own wallets,** or from an owner who tops the wallet up | `never` (the wallet's own address is always on it) |
| today's returns are **under the caps** | `perRun` 3 and `perDay` 30. nano133.com adds a cap per sender: 10 a day |

These are the options of the kit's `late-returns` package (`src/index.ts`, `findLateReturns`). The cap per sender is its `perSenderPerDay`, which is off by default; nano133.com sets it to 10.

> **Rule:** Decide from the payment's own block, never from the list alone. When the node does not answer, skip the payment for this run. Never return a payment that is not confirmed, that is younger than the grace of your checkouts, that a checkout can still claim, or that came from your own wallets.

**Check:** `node return-once.mjs why <hash> <wallet>` answers `goes back to <sender>`, or `stays: <the reason>`: not confirmed, not a send, sent to another address, below the smallest payment, a sender that never gets a return, too young, or an open checkout can still claim it. Each reason has its own answer.

## Step 3: Record the decision, and mark the payment used

**Do:** Record the return, the used mark and the counters in one transaction.

```
BEGIN;
  -- refuse when a return exists for this payment, or the payment is used, or the day is full
  INSERT INTO returns (hash, amount, to_address, status, …) VALUES (…, 'pending', …);
  INSERT INTO used (hash) VALUES (…);
  UPDATE counters SET n = n + 1 WHERE key = …;      -- the day, and the sender with the day
COMMIT;
```

When the rules say that a payment must go back, write it down in **one transaction**, so that either all of this happens or none of it:

1. create the return, as *pending*, with the amount and the sender's address;
2. mark the payment **used,** so that no checkout can claim it later;
3. count it for the day and for the sender.

The address that the money goes back to is the **sender of the payment's block** (`block_account`), read from the node. It never comes from a request.

A sender that already had 10 returns today does not get an eleventh. The payment is recorded as *kept*: marked used, not sent, and not counted in the day. Someone flooding you with small wrong payments then costs you ten returns, not thousands. A person can ask for a kept payment, and you send it by hand (`release`): it goes back with the next run, once. Say all of this in your terms.

Your checkout's claim must do the other half: refuse a payment whose hash is already used, in its own transaction. Then a payment can pay a checkout or go back, and never both.

> **Rule:** Record the return, the used mark and the counters in one transaction. Your claim code must refuse a payment whose hash is used. The address to send back to is read from the payment's block.

**Check:** run `scan` twice: one return, not two. A payment that a checkout claimed is not recorded. A payment that a return recorded cannot be claimed (`claim` answers `refused`). A sender's third payment is `kept` when the cap per sender is 2.

## Step 4: Receive it, and send exactly its amount back

**Do:** Receive the payment, then send exactly its amount back under the wallet's lock.

```
const received = await receiveOne({ lock, account: wallet, privateKey, hash: job.hash, state });   // null: received before
await sendOnce({ db, lock, account: wallet, privateKey, job: job.hash, to: job.to_address, amount: BigInt(job.amount), state });
UPDATE returns SET status = 'returned', return_hash = ? WHERE hash = ?;
```

Now do the money, under the wallet's lock, one return at a time:

1. **Receive that one payment, by its hash.** If the ledger answers that it was received already (a payout run took it in), go on: the balance holds it.
2. **Send exactly its amount to the sender,** with the safe send of the other guide. The job of that send is **the payment's hash**: one payment, one send, however often this runs.
3. **Mark the return done,** with the hash of the send.

The buyer's wallet shows the return as a payment of the same amount. There is no fee in Nano, so the buyer gets back exactly what they sent.

**Check:** run `node return-once.mjs run ./returns.db 0` for that payment: it prints `recorded 1, returned 1`. The ledger holds one send, of exactly 0.002 XNO, to the address that paid, and the wallet holds none of it. Run it again: `recorded 0, returned 0`, and still one send.

## Step 5: Survive a stop, and run on a timer

**Do:** Record each stage, so that a stop is harmless, and run it again from a timer.

A run can stop anywhere: after the decision, after the receive, after the block is signed, after the node took the block. Each stage is recorded, and the next run goes on from the record:

- **The return is recorded, nothing else:** the next run receives and sends.
- **The payment is received:** the return remembers it (`recv`). If something else took the payment in, the return remembers `earlier`.
- **The send is signed and recorded:** the next run publishes the same block, or finds it on the ledger and sends nothing.
- **A return cannot be sent now** (the wallet does not hold the amount, or the node does not answer): it stays *pending*, with its error and a count of tries, and the next run tries again. nano133.com logs the 1st, 2nd, 4th, 8th failure of a return, not each one.

Run it from a timer every few minutes. Take the wallet's lock first (the lock of the other guide: the one lock that every piece of code signing for the wallet uses), so that two runs never work on the wallet together. A run that finds the lock taken stops and tries later. Give the scan and the returns the same lock, in the same run: a scan that fails does not stop the returns that are recorded.

> **Rule:** Run returns from a timer, never from a visitor's request, under the wallet's one lock. A failed return stays pending and is tried again by the next run. Never send a return that a record says went out.

**Check:** stop a run after it signed the send: the ledger holds nothing, and a later run sends the recorded block, once. Stop one after the node took the block: the later run sends nothing more. Start two runs at once: one works, the other answers `busy`.

## Step 6: Keep the limits, and tell people

**Do:** Keep the caps, say what you do in your terms, and keep the wallet small.

- **Keep the caps.** Each return is two blocks of proof of work from your node: a receive and a send. The caps keep a flood of small payments from turning into work.
- **Say what you do in your terms:** that late and wrong payments are returned after about two hours, that a sender who sends many has later payments kept, and how to ask for a kept payment.
- **Tell buyers about exchanges:** a payment from an exchange goes back to the exchange's address, not to the person's account there.
- **Keep the wallet small.** Whatever the returns do not need should move to a wallet whose key is not on a server.

**Check:** `release` turns a *kept* payment into a pending one, and the next run sends it back, once. `release` leaves a payment that is not kept alone, and answers `missing` for one it does not know.

## 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: customers pay it
```

```
export NANO_RPC=https://your-node.example/rpc
node return-once.mjs why <payment hash> <the wallet's address>
node return-once.mjs run ./returns.db 0 --claimable <amounts that open checkouts can still claim>
node return-once.mjs list ./returns.db
```

File `return-once.mjs` (https://nano133.com/guides/send-money-back-exactly-once/code/return-once.mjs):

```js
// Send a payment that nothing claimed back to the address that sent it, exactly once.
// From the nano133.com guide "Send money back exactly once". MIT license.
// Node.js 22 or newer (node:sqlite) and two packages (npm install nanocurrency-web @noble/hashes).
// It needs safe-send.mjs, which sits next to it: the file of the guide "Sign blocks from a server safely", unchanged.
//
//   export WALLET_SECRET=$(openssl rand -hex 32)       # a throwaway secret for a test
//   NANO_RPC=… node return-once.mjs run <database> <index> [limits]
//       finds the payments to wallet <index> that must go back, records each one, and sends each one back, once.
//   NANO_RPC=… node return-once.mjs scan <database> <index> [limits]      only finds and records them (it sends nothing)
//   NANO_RPC=… node return-once.mjs settle <database> <index> [limits]    only sends the ones that are recorded
//   NANO_RPC=… node return-once.mjs why <payment hash> <wallet address> [limits]
//                                                                         says whether that payment goes back, and why not (it only reads)
//   NANO_RPC=… node return-once.mjs candidates <wallet address> [limits]
//                                                                         prints the payments that a scan would record (it only reads, and needs no database)
//   node return-once.mjs list <database>                                  prints every return the database holds
//   NANO_RPC=… node return-once.mjs release <database> <payment hash>     a kept payment goes back after all (by hand)
//   node return-once.mjs claim <database> <payment hash>                  stands in for your checkout's claim: it marks a payment used
//
// Limits, all optional:
//   --min-raw <raw>       the smallest payment that goes back (default 10^27, 0.001 XNO)
//   --min-age-sec <s>     how old a payment must be (default 7200, 2 hours: longer than any checkout's grace)
//   --per-run <n>         new returns in one run (default 3)        --per-day <n>     in one UTC day (default 30)
//   --per-sender <n>      per sender per UTC day (default 10); later payments of that sender are kept, not sent
//   --never <addresses>   senders that never get a return: your own wallets, an owner's top-ups (a comma list)
//   --claimable <raws>    amounts that an open checkout can still claim (a comma list). In your site this is one query.
//
// A test never sends money: use a mock node or a tiny amount. Never print or log a seed or a key.

import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import { accountOf, LockLost, lockOf, openDatabase, receiveOne, sendOnce, stateOf } from "./safe-send.mjs";

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 sha256 = (s) => createHash("sha256").update(s).digest("hex");
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);

// ---- Step 3. The records: a return, the used mark of its payment, and the counters -------------------------------

export function openReturns(path) {
  const db = openDatabase(path); // the locks and the sends of safe-send.mjs
  db.exec(`CREATE TABLE IF NOT EXISTS returns (
      hash TEXT PRIMARY KEY,                       -- the payment's send block hash: one payment, one return
      amount TEXT NOT NULL, to_address TEXT NOT NULL, -- exactly what arrived, and where it goes back to: the sender
      status TEXT NOT NULL,                        -- pending | returned | kept
      by TEXT NOT NULL, day TEXT, created_at INTEGER NOT NULL,
      recv TEXT,                                   -- the block that received the payment, or 'earlier'
      return_hash TEXT, error TEXT, tries INTEGER NOT NULL DEFAULT 0
    );
    CREATE TABLE IF NOT EXISTS used (hash TEXT PRIMARY KEY);                        -- payments that a checkout claimed, or that a return took
    CREATE TABLE IF NOT EXISTS counters (key TEXT PRIMARY KEY, n INTEGER NOT NULL);  -- returns for each day, and for each sender and day`);
  return db;
}

/**
 * Records a return in ONE transaction: it creates the pending return, marks the payment used (so that no checkout can
 * claim it later) and counts the day. Or it does none of it. Answers "created", "exists", "used", "limit" or "kept".
 * Your own claim code must refuse a payment whose hash is in `used`.
 */
export function record(db, { hash, amount, to, by }, { day, perDay, perSender }) {
  const count = (key) => db.prepare("SELECT n FROM counters WHERE key = ?").get(key)?.n ?? 0;
  const bump = (key) => db.prepare("INSERT INTO counters (key, n) VALUES (?, 1) ON CONFLICT (key) DO UPDATE SET n = n + 1").run(key);
  const add = (status) => {
    db.prepare("INSERT INTO returns (hash, amount, to_address, status, by, day, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(hash, amount, to, status, by, day, Date.now());
    db.prepare("INSERT INTO used (hash) VALUES (?)").run(hash);
  };
  db.exec("BEGIN IMMEDIATE");
  try {
    let result;
    const senderKey = `sender|${day}|${sha256(to)}`; // the address is not in the key
    if (db.prepare("SELECT 1 FROM returns WHERE hash = ?").get(hash)) result = "exists";
    else if (db.prepare("SELECT 1 FROM used WHERE hash = ?").get(hash)) result = "used";
    else if (perSender !== undefined && count(senderKey) >= perSender) (add("kept"), (result = "kept")); // marked used, never sent
    else if (count(`day|${day}`) >= perDay) result = "limit";
    else (add("pending"), bump(`day|${day}`), bump(senderKey), (result = "created"));
    db.exec("COMMIT");
    return result;
  } catch (e) {
    db.exec("ROLLBACK");
    throw e;
  }
}

// ---- Steps 1 and 2. Find the payments, and decide which must go back ---------------------------------------------

/**
 * Why a payment stays where it is, or null when it must go back. `info` is the node's block_info of the payment's block, and
 * `limits` the rules above. The checks are on the block itself, never on a list that says it is there.
 */
export function whyItStays(info, wallet, limits, now = Date.now()) {
  if (!info || info.confirmed !== "true") return "it is not confirmed";
  if ((info.subtype ?? info.contents?.subtype) !== "send") return "it is not a send";
  if (info.contents.link_as_account !== wallet) return "it was sent to another address";
  if (BigInt(info.amount) < limits.minRaw) return "it is below the smallest payment that goes back";
  const from = info.block_account;
  if (from === wallet || limits.never.includes(from)) return "its sender never gets a return";
  const seenAt = Number(info.local_timestamp ?? 0) * 1000;
  if (!seenAt || now - seenAt < limits.minAgeSec * 1000) return "it is too young";
  if (limits.claimable.includes(info.amount)) return "an open checkout can still claim it";
  return null;
}

/**
 * Finds the late payments to one wallet and records their returns. It sends nothing. Answers how many it recorded.
 * With `dry`, it records nothing and needs no database: it answers the payments that it would record (up to `perRun`).
 */
export async function findReturns({ db, wallet, limits, now = Date.now(), dry = false }) {
  const waiting = await rpc({ action: "receivable", account: wallet, count: "50", threshold: limits.minRaw.toString(), source: "true" });
  const day = utcDay(now);
  let made = 0;
  const would = [];
  for (const [id, row] of Object.entries(waiting.blocks || {})) {
    if (made >= limits.perRun) break;
    const hash = id.toUpperCase();
    if (!dry && db.prepare("SELECT 1 FROM returns WHERE hash = ? UNION SELECT 1 FROM used WHERE hash = ?").get(hash, hash)) continue;
    // Never go by the list alone: ask the node for the block itself.
    const info = await rpc({ action: "block_info", hash, json_block: "true" }).catch(() => null);
    if (!info || info.amount !== row.amount || whyItStays(info, wallet, limits, now)) continue;
    if (dry) {
      would.push({ hash, amount: row.amount, to: info.block_account });
      made++;
      continue;
    }
    const result = record(db, { hash, amount: row.amount, to: info.block_account, by: "auto" }, { day, perDay: limits.perDay, perSender: limits.perSender });
    if (result === "limit") break;
    if (result === "created") made++;
  }
  return dry ? would : made;
}

// ---- Step 4. Receive the payment, and send exactly its amount back, once ------------------------------------------

/** Sends the pending returns back, each once. Call it while you hold the wallet's lock. Answers how many it returned. */
export async function sendReturns({ db, lock, wallet, privateKey, limits }) {
  let returned = 0;
  for (const job of db.prepare("SELECT * FROM returns WHERE status = 'pending' ORDER BY created_at LIMIT ?").all(limits.perRun * 2)) {
    try {
      lock.record(); // the fence: stop at once if the lock is gone
      let state = await stateOf(wallet);
      if (!job.recv) {
        // Receive this job's own payment first, by its hash. A return is never sent until its payment is in the wallet.
        const received = await receiveOne({ lock, account: wallet, privateKey, hash: job.hash, state });
        lock.record(() => db.prepare("UPDATE returns SET recv = ? WHERE hash = ?").run(received ?? "earlier", job.hash)); // null: something took it in before
        state = await stateOf(wallet);
      }
      // The job of the safe send is the payment's hash: one payment, one send, however often this runs.
      const sent = await sendOnce({ db, lock, account: wallet, privateKey, job: job.hash, to: job.to_address, amount: BigInt(job.amount), state });
      lock.record(() => db.prepare("UPDATE returns SET status = 'returned', return_hash = ?, error = NULL WHERE hash = ?").run(sent.hash, job.hash));
      returned++;
    } catch (e) {
      if (e instanceof LockLost) break; // whoever holds the lock now carries on
      // Leave it pending. The next run checks the ledger before it sends anything.
      try {
        lock.record(() => db.prepare("UPDATE returns SET error = ?, tries = tries + 1 WHERE hash = ?").run(String(e?.message ?? e).slice(0, 200), job.hash));
      } catch {
        /* the lock is gone too */
      }
    }
  }
  return returned;
}

/**
 * What your checkout's claim does with a payment, in your own claim transaction: it marks the payment used, and refuses one
 * that is used already (a return took it, or another claim did). Answers true when the claim got the payment.
 */
export function claim(db, hash) {
  db.exec("BEGIN IMMEDIATE");
  try {
    const free = !db.prepare("SELECT 1 FROM used WHERE hash = ?").get(hash);
    if (free) db.prepare("INSERT INTO used (hash) VALUES (?)").run(hash);
    db.exec("COMMIT");
    return free;
  } catch (e) {
    db.exec("ROLLBACK");
    throw e;
  }
}

/** A kept payment goes back after all: its sender is read again from the node, and the next run returns it once. */
export async function releaseKept(db, hash) {
  const H = hash.toUpperCase();
  const row = db.prepare("SELECT status FROM returns WHERE hash = ?").get(H);
  if (!row) return "missing";
  if (row.status !== "kept") return "not-kept";
  const info = await rpc({ action: "block_info", hash: H, json_block: "true" });
  db.prepare("UPDATE returns SET status = 'pending', to_address = ?, by = 'released' WHERE hash = ? AND status = 'kept'").run(info.block_account, H);
  return "released";
}

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

function limitsOf(args) {
  const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
  const list = (v) => (v ? v.split(",").map((x) => x.trim()).filter(Boolean) : []);
  const whole = (v, fallback, name) => {
    if (v === undefined) return fallback;
    if (!/^[0-9]+$/.test(v)) throw new Error(`${name} is a whole number.`);
    return Number(v);
  };
  return {
    minRaw: BigInt(flag("--min-raw") ?? 10n ** 27n),
    minAgeSec: whole(flag("--min-age-sec"), 7200, "--min-age-sec"),
    perRun: whole(flag("--per-run"), 3, "--per-run"),
    perDay: whole(flag("--per-day"), 30, "--per-day"),
    perSender: whole(flag("--per-sender"), 10, "--per-sender"),
    never: list(flag("--never")),
    claimable: list(flag("--claimable")),
  };
}

async function main(args) {
  const [command, path, third] = args;
  if (command === "list") {
    for (const r of openReturns(path).prepare("SELECT hash, amount, to_address, status, recv, return_hash, error, tries FROM returns ORDER BY created_at, hash").all()) console.log(JSON.stringify(r));
    return;
  }
  if (command === "claim") return console.log(claim(openReturns(path), third.toUpperCase()) ? "claimed" : "refused: that payment was used already");
  if (!process.env.NANO_RPC) throw new Error("Set NANO_RPC to your node's RPC URL.");
  if (command === "candidates") {
    for (const c of await findReturns({ wallet: path, limits: limitsOf(args.slice(2)), dry: true })) console.log(JSON.stringify(c));
    return;
  }
  if (command === "why") {
    const info = await rpc({ action: "block_info", hash: path.toUpperCase(), json_block: "true" });
    const why = whyItStays(info, third, limitsOf(args.slice(3)));
    return console.log(why ? `stays: ${why}` : `goes back to ${info.block_account}`);
  }
  const db = openReturns(path);
  if (command === "release") return console.log(await releaseKept(db, third));
  if (!["run", "scan", "settle"].includes(command)) throw new Error("Commands: run, scan, settle, candidates, why, list, release, claim. Read the top of this file.");
  const acct = accountOf(process.env.WALLET_SECRET, Number(third));
  const limits = limitsOf(args.slice(3));
  const lock = lockOf(db, acct.address);
  if (!lock.take()) return (console.error("busy: another run holds this wallet's lock"), process.exit(4));
  try {
    let recorded = 0;
    if (command !== "settle") recorded = await findReturns({ db, wallet: acct.address, limits }).catch((e) => (console.error(`scan failed: ${e?.message ?? e}`), 0));
    const returned = command === "scan" ? 0 : await sendReturns({ db, lock, wallet: acct.address, privateKey: acct.privateKey, limits });
    console.log(`recorded ${recorded}, returned ${returned}`);
  } finally {
    lock.release();
  }
}

// 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?.message ?? e);
    process.exit(1);
  });
}
```

File `safe-send.mjs` (https://nano133.com/guides/send-money-back-exactly-once/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.** Whoever has it can move the money at its wallets. **Checked so far:** the whole flow against a local copy of the ledger (real signatures, real block hashes, concurrent runs, stops at each stage), and steps 1 and 2 against a real node (25 October 2026): the list of what waits at an address, and the decision on its first payment, with the smallest payment set to 0.000001 XNO. **Not checked:** a return on the real network. Test it with a tiny amount first. `safe-send.mjs` is the same file as in the other guide.

## Or use the kit

[Late Payment Return](https://nano133.com/build/late-payment-return) is this as a package: `runLateReturns` with the rules of step 2 as options, a store for Firestore or your own database, and `releaseKept`. It is the code that nano133.com runs for its till. With an address for each checkout, as in [Accept Nano payments without a processor](https://nano133.com/guides/accept-nano-payments), give it those addresses and a signer for each, and a payment of any other amount at a checkout's address goes back in full from that address. Install it with `npm install @nano133/late-returns`.

## What breaks, and what it costs

| Failure | Loss in Nano | Check |
| --- | --- | --- |
| A payment is returned and also pays a checkout | The shop gives the goods and returns the money | Step 3: one transaction for the return and the used mark, and a claim that refuses a used hash |
| A return is sent twice | The buyer gets the amount twice | Steps 4 and 5: the payment's hash is the job of the safe send, and each stage is recorded |
| A payment is returned while its checkout can still claim it | The buyer's good payment goes back, and they get nothing, or the checkout is paid and the payment goes back too | Step 2: wait 2 hours, and ask your checkouts |
| A payment that is not confirmed is returned | A send of money that the wallet may not have | Step 2: confirmed blocks only |
| The return goes to an address that came from a request | The money goes to whoever wrote the request | Step 3: the sender's address is read from the payment's block |
| The sender is an exchange | The money goes to the exchange's address, not to the person's account there, and may not reach them | Step 6: tell buyers |
| An owner's top-up is returned | You send your own money away | Step 2: your own wallets and the owner's address are on the never list |
| A flood of small payments | Two blocks of work for each one, and the wallet's work grows with the flood | Steps 2, 3 and 6: the smallest payment, and the caps for each run, day and sender |
| The node errs during the scan and the code decides anyway | A payment is returned that should stay, or the other way | Step 2: skip the payment this run |
| A return waits with an error for ever | The buyer's money stays in the wallet | Step 5: the next run tries again, and failures are logged |

## Sources

- [Late Payment Return](https://nano133.com/build/late-payment-return) · the same pattern as a package, from the nano133 Kit
- [Sign blocks from a server safely](https://nano133.com/guides/sign-blocks-safely) · the safe send that this guide uses, step by step
- [Accept Nano payments without a processor](https://nano133.com/guides/accept-nano-payments) · records the payments that a checkout claimed
- [Nano node RPC reference](https://docs.nano.org/commands/rpc-protocol/) · receivable, block_info, account_info, account_history, work_generate, process
- [nanocurrency-web on GitHub](https://github.com/numsu/nanocurrency-web-js) · signed blocks (MIT)
- [nano.org](https://nano.org/) · the Nano Foundation

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