From the explanation to a working request

Issue two credits. Spend one. Retry safely.

This example talks to the same classical issuer as the browser wallet. It creates a token, spends one credit, verifies the change, and resends both requests to check that retries replay the original responses.

For the motivation, read Rate Limits Without Identity by Sam Schlesinger. To see the protocol before looking at code, try the browser wallet.

The issuer is experimental and unaudited. These credits have no monetary value. This small script keeps secrets in memory and discards its remaining credit when it exits. It demonstrates identical retries; it does not recover state after a process crash. The browser wallet journals its operations.

Run the example

You need Node.js 22.7 or later and curl. Download the example and the same WebAssembly engine used by the wallet into a new directory. The small package.json file tells Node to load JavaScript as modules. There are no npm packages to install. Mining uses your CPU and may take a moment.

mkdir act-example
cd act-example
mkdir wallet
printf '{"type":"module"}\n' > package.json
curl --fail --show-error --silent --location \
  https://anonymous-credit-tokens.info/static/issue-and-spend.mjs \
  --output issue-and-spend.mjs
curl --fail --show-error --silent --location \
  https://anonymous-credit-tokens.info/static/wallet/act_info_browser.js \
  --output wallet/act_info_browser.js
curl --fail --show-error --silent --location \
  https://anonymous-credit-tokens.info/static/wallet/act_info_browser_bg.wasm \
  --output wallet/act_info_browser_bg.wasm
node issue-and-spend.mjs https://anonymous-credit-tokens.info

The script reports a verified balance of 2, then a change balance of 1. Each repeated request must carry replayed: true and the same cryptographic response as the first request. An unexpected balance, response, or HTTP error stops the example. Token secrets are never printed.

What crosses the network

StepSent to the issuerKept by the client
IssueBlinded request, proof-of-work solution, public grantSecret issuance state, then the token
SpendSpend proof and requested returnOld token and state needed to finish the change token
RetryThe identical serialized requestThe same pending operation

Unlinkability is a property of the token protocol. Network addresses, timing, request contents, and application logs can still correlate use. See the deployment's privacy boundaries.

Using this in a metered API

A successful spend accounts for credits. Connecting it to an application operation, such as serving a document, also needs an application contract. The demo issuer exposes the token protocol; it does not provide that application transaction for you.

  1. Define what one credit buys and bind that operation to the authorized spend. A payment for one operation must not authorize a different one.
  2. Persist the exact client request and secret completion state before sending. If the reply is lost, resend the stored request instead of generating another proof.
  3. Commit credit consumption and the application result together, or use a recoverable transaction design. On an identical retry, return the stored result without delivering or charging twice.
  4. Choose an issuance policy. This demonstration charges proof of work; it does not enforce one grant per person or a monthly allowance.

The failure-semantics guide explains the issuer's durable replay contract. The API reference lists the envelopes and errors.

Complete source

Download the client example

Read the example (Node.js)
// Disposable classical ACT client example. Node 22.7 or later, no npm packages.
// Secrets live in memory only. Use the browser wallet for journaled recovery.
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import init, {
  prepare_act_issue, mine_act_issue, finish_act_issue,
  prepare_act_spend, finish_act_spend,
} from "./wallet/act_info_browser.js";

export async function runExample(origin, report = console.log) {
  const url = new URL(origin);
  const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
  if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) ||
      url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
    throw new Error("Use an HTTPS issuer origin, or HTTP localhost for a local test.");
  }
  const base = url.origin;

  async function api(path, body) {
    const response = await fetch(base + path, {
      method: body === undefined ? "GET" : "POST",
      headers: body === undefined ? undefined : { "content-type": "application/json" },
      body,
      redirect: "error",
      signal: AbortSignal.timeout(30_000),
    });
    // Middleware can return plain text for rate or size limits.
    if (!response.ok) {
      throw new Error(`Issuer returned HTTP ${response.status} for ${path}. This example stops; it does not retry automatically.`);
    }
    return response.json();
  }

  await init({ module_or_path: await readFile(new URL("./wallet/act_info_browser_bg.wasm", import.meta.url)) });
  const params = await api("/api/v1/act/params");
  report("Connected to the classical issuer. Token secrets will stay in this process.");

  // Prepare once. Mining is bound to these exact blinded request bytes.
  const prepared = JSON.parse(prepare_act_issue(params.domain_separator));
  const challenge = await api("/api/v1/act/challenge", JSON.stringify({ credits: 2 }));
  report(`Mining a ${challenge.difficulty_bits}-bit challenge for 2 credits...`);
  const mined = JSON.parse(mine_act_issue(
    challenge.challenge_id, challenge.salt, prepared.request, challenge.difficulty_bits,
  ));
  const issueBody = JSON.stringify({
    challenge_id: challenge.challenge_id, nonce: mined.nonce, request: prepared.request,
  });
  const issuedReply = await api("/api/v1/act/issue", issueBody);
  const issued = JSON.parse(finish_act_issue(
    params.domain_separator, params.public_key, challenge.challenge_id,
    prepared.pre_issuance, prepared.request, issuedReply.response,
  ));
  assert.equal(issued.balance, 2);
  report("Issued 2 credits. The client verified the issuer's response.");

  // Demonstrate replay by resending the SAME serialized request.
  const issueReplay = await api("/api/v1/act/issue", issueBody);
  assert.equal(issueReplay.replayed, true);
  assert.equal(issueReplay.response, issuedReply.response);
  report("Replayed issuance: the same response, with no second grant.");

  const spending = JSON.parse(prepare_act_spend(params.domain_separator, issued.token, 1n));
  const spendBody = JSON.stringify({ request: spending.proof, requested_return: 0 });
  const spentReply = await api("/api/v1/act/spend", spendBody);
  const change = JSON.parse(finish_act_spend(
    params.domain_separator, params.public_key, spending.proof,
    spending.pre_refund, spentReply.response,
  ));
  assert.equal(change.balance, 1);
  assert.equal(spentReply.returned, 0);
  report("Spent 1 credit. A new change token holds the remaining 1 credit.");

  const spendReplay = await api("/api/v1/act/spend", spendBody);
  assert.equal(spendReplay.replayed, true);
  assert.equal(spendReplay.response, spentReply.response);
  report("Replayed spend: the same response, with no second charge.");
  report("Done. The remaining demonstration credit is discarded when this process exits.");
  return { balance: change.balance, issueReplayed: true, spendReplayed: true };
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  if (!process.argv[2]) {
    console.error("Usage: node issue-and-spend.mjs https://anonymous-credit-tokens.info");
    process.exitCode = 1;
  } else {
    try { await runExample(process.argv[2]); }
    catch (error) {
      console.error(error.message);
      process.exitCode = 1;
    }
  }
}

Open the browser walkthrough