Skip to content

Verified Frontends signing format ​

A Verified Frontends record is a Solana transaction that Stasho prepares and pays for, and that your publishing key signs. This page specifies the whole exchange, so your team can write the client in any language and keep Stasho's code out of the signing path. The @stasho/vf CLI is one implementation of it; the reference client at the end of this page is another, with no Stasho package in it.

The security property is the order of operations: nothing is signed until the prepared message passes the checks. Stasho chooses the fee payer, the blockhash and the timestamp, and the timestamp is held to one exact ISO 8601 form within 10 minutes of your clock. Everything else in the message is something you asked for, and the checks prove it is exactly that before your key touches it.

The exchange ​

  1. POST /api/vf-cli/publish/prepare with the record you want. Stasho answers with the exact transaction message bytes.
  2. Run the checks on those bytes. Refuse to continue if any fails.
  3. Sign the bytes with your key (ed25519).
  4. POST /api/vf-cli/publish/submit with the signature. Stasho adds its fee-payer signature, broadcasts, waits for confirmation, and returns the transaction signature.
  5. Optionally, read the transaction back from Solana and confirm the record.

Base URL: https://api.stasho.xyz. Both requests are authenticated with the request envelope, signed by the same key.

Request envelope ​

Every request carries one header:

Authorization: VfKey <message>.<signature>
  • <message> is the envelope message bytes, base64url without padding (A-Za-z0-9_- only).
  • <signature> is the 64-byte ed25519 signature of those exact bytes by your publishing key, base64url without padding.
  • VfKey, one space, then the two parts joined by a single ..

The message is UTF-8 text: the prefix stasho-vf:1:auth: followed by a JSON object with exactly these six keys.

keyvalue
vthe number 1
mthe HTTP method, "POST"
pthe request path exactly as sent, e.g. "/api/vf-cli/publish/prepare" (no scheme, host or query)
dthe domain you are acting on, in canonical form
tthe current time as an ISO 8601 string, e.g. "2026-09-07T12:00:00.000Z"
hthe lowercase hex SHA-256 of the exact request body bytes
stasho-vf:1:auth:{"v":1,"m":"POST","p":"/api/vf-cli/publish/prepare","d":"app.example.com","t":"2026-09-07T12:00:00.000Z","h":"<64 hex chars>"}

What the server enforces:

  • The key set is exactly v, m, p, d, t, h, no more and no fewer. The server verifies your signature over the bytes you sent, so the key order inside the JSON is yours to choose; the CLI and the reference client both emit v, m, p, d, t, h.
  • m and p equal the request's method and path.
  • t parses as a date within 10 minutes of the server clock, either direction.
  • h is computed over the raw body bytes as they arrive. Serialize the body once and send that same string; do not re-serialize it after hashing. An empty body still hashes (sha256("")).
  • d is already canonical; the server does not normalize it.
  • A live binding exists for d, and the signature verifies against the key bound to it. For an accept, only the pending (incoming) key is admitted, and only while a transfer is pending.
  • On prepare, the domain in the body names the same domain as d.

A request inside the 10-minute window can be replayed. That is by design: a replayed prepare hits the one-in-flight lock and the daily limit, and a replayed submit returns the signature of the transaction that already landed.

Canonical domain ​

Lowercase, ASCII (an internationalized name in its xn-- punycode form), no trailing dot, at least two labels, each label 1 to 63 characters of a-z, 0-9 and - that neither starts nor ends with -, and at most 253 characters in total.

Test vector ​

Use it to check your envelope code. Ed25519 signatures are deterministic, so your output must match byte for byte.

inputvalue
seed (32 bytes)every byte 0x01
public keyAKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9
path/api/vf-cli/publish/prepare
domaina.example
body{"domain":"a.example","cid":"bafyone"}
time2026-09-07T12:00:00.000Z

Message:

stasho-vf:1:auth:{"v":1,"m":"POST","p":"/api/vf-cli/publish/prepare","d":"a.example","t":"2026-09-07T12:00:00.000Z","h":"2fbfc68218e84a70712e05ffb238ad2cd80bf9035fb2247525608b432bacaf7f"}

Header:

VfKey c3Rhc2hvLXZmOjE6YXV0aDp7InYiOjEsIm0iOiJQT1NUIiwicCI6Ii9hcGkvdmYtY2xpL3B1Ymxpc2gvcHJlcGFyZSIsImQiOiJhLmV4YW1wbGUiLCJ0IjoiMjAyNi0wOS0wN1QxMjowMDowMC4wMDBaIiwiaCI6IjJmYmZjNjgyMThlODRhNzA3MTJlMDVmZmIyMzhhZDJjZDgwYmY5MDM1ZmIyMjQ3NTI1NjA4YjQzMmJhY2FmN2YifQ.2_AmlEGbgUM8rUcoG2cYI9WwHJNYYtdGFETeR51yQtUEe611CPGbuDGYl2YrtwR2RbrTlny15ELlee3wrlclAg

Prepare ​

POST https://api.stasho.xyz/api/vf-cli/publish/prepare
Content-Type: application/json
Authorization: VfKey …

{"domain":"app.example.com","cid":"bafybei…","version":"v1.2.3"}
fieldrequirednotes
domainyesthe domain the record is for; must name the same domain as the envelope's d
cidyesthe content identifier the domain should serve. Stasho does not validate it; see computing the CID
versionnoyour build identity: a git SHA, a tag, a deployment id. Omitted or blank becomes "0"
opnoomit it (or send "pub") to publish. "transfer" and "accept" are below

The server trims surrounding whitespace from domain, cid and version before building the record. Send them already trimmed, or the check will refuse the record the server built.

Response 200:

json
{
  "prepareId": "5f0c…",
  "message": "AgEBA…",
  "feePayer": "9NW8Q41TLccp4uMAD6Nd5bLR11smCHUTZy9uDdbTUfps",
  "lastValidBlockHeight": 424242424,
  "payload": "stasho-vf:1:pub:{\"d\":\"app.example.com\",\"c\":\"bafybei…\",\"v\":\"v1.2.3\",\"t\":\"2026-09-07T12:00:00.000Z\"}",
  "expiresAt": 1788782460000,
  "txtObserved": { "vault": "solana:E1Eq…", "agreeing": 2, "required": 2, "reason": "ok", "at": 1788782000000 }
}
fieldmeaning
prepareIdthe handle for submit (a random UUID)
messagethe legacy Solana transaction message, standard base64. These are the bytes you check and sign.
feePayerStasho's fee-payer address, base58 (account 0 of the message)
lastValidBlockHeightthe blockhash's expiry height; the transaction cannot land after it
payloadthe record text, for display. Check the bytes inside message, never this field
expiresAtwhen this prepare expires, milliseconds since the epoch (currently 60 seconds after prepare)
txtObservedwhat Stasho last saw in the domain's _stasho-vf TXT record, or null if it has not checked. Informational: a vault other than your key is worth a warning, not a failure, since a resolver can flake

Submit ​

POST https://api.stasho.xyz/api/vf-cli/publish/submit
Content-Type: application/json
Authorization: VfKey …

{"prepareId":"5f0c…","signature":"<base64 of 64 bytes>"}

Sign the envelope with the same d as the prepare; this body carries no domain field of its own.

signature is the ed25519 signature (RFC 8032, plain Ed25519, no pre-hashing) of the raw message bytes, that is the base64-decoded message from prepare, exactly. 64 bytes, standard base64.

The call returns once the transaction is confirmed on Solana, which can take up to about three minutes. Set your HTTP timeout above that.

Response 200:

json
{ "signature": "5h1qrnm8He39e8j38dbM2PjyWj98Q8wjPdpkRKwhVrCrcaffMY6dLuUxfby1b5ApC2FaianYrjx4HHcAGwp8hnf1" }

signature is the transaction id (base58), which is the fee payer's signature. Open it at https://explorer.solana.com/tx/<signature>.

Submit is idempotent once the transaction has landed: if the response was lost, send the same prepareId and signature again within 60 seconds and you get the same transaction signature back, not a second record.

Errors ​

A refusal body is JSON {"error": "…"}, except the blanket 404 and the 413, which are plain text.

statusmeaningwhat to do
404, body 404 Not Found (plain text)one answer for every authentication failure: bad envelope, wrong key, no live binding, domain mismatch, feature not enabled for the account. Deliberately indistinguishable, so a probe learns nothingcheck the domain, the key, and the envelope against the test vector
400a malformed field: missing domain/cid/prepareId/signature, unknown op, a record too large for one transaction, a signature that is not 64 bytes or does not verify against the prepared messagefix the request. A bad signature leaves the prepare alive until it expires
409the binding is not in a publishable state; another publish for this domain is in flight; this prepare is already being submitted; or the key is frozen by a transfer ("cause": "transfer-frozen")wait for the other publish, or resolve the binding state
410the prepare expired (unknown or expired prepareId), or the blockhash expired before the transaction confirmedprepare again, once. Once its blockhash has expired a transaction can no longer land, so the new prepare does not double-publish. The one exception is a cluster so slow that Stasho stops waiting after about three minutes while the blockhash is still valid; that 410 says safety ceiling, and the first transaction can still land. A second 410 in a row means something is wrong (a clock, a stuck cluster): surface it rather than loop
413, body Payload Too Large (plain text)request body over 64 KiBsend a smaller body
429the per-domain daily publish limit (currently 30 per rolling 24 hours), or the per-IP limit on this API (currently 30 requests per minute, with Retry-After)wait
502Stasho's on-chain path failed, including a transaction the cluster rejectedretry later
503the node that answered is not the one that may write: {"error":"not-primary","reason":"…"} from a standby, or {"error":"backend is passive"} from a node with no publisherretry later

The limits are operational values and can change.

The transaction ​

The message is a legacy Solana transaction message (not versioned), built by Stasho with web3.js. The genuine shape:

partvalue
headernumRequiredSignatures = 2 (then numReadonlySignedAccounts = 1, numReadonlyUnsignedAccounts = 1)
account 0Stasho's fee payer, a signer (it pays the fee)
account 1your publishing key, a read-only signer
account 2the SPL Memo program, MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr
recent blockhashchosen by Stasho
instructionsexactly one: program = the memo program, accounts = [1] (your key only), data = the record

The memo program logs Signed by <address> for each account passed to it, after verifying that account signed. Because your key is the instruction's only account, the transaction logs Signed by <your key> and nothing else. That log line is what makes the record yours, and it is what every verifier reads.

The record ​

The instruction data is UTF-8 text, the prefix stasho-vf:1:, the record kind, :, then a JSON object in exactly this form, keys in this order, no whitespace, strings escaped the way JSON.stringify escapes them:

stasho-vf:1:pub:{"d":"<domain>","c":"<cid>","v":"<version>","t":"<ISO 8601 time>"}
keymeaning
dthe domain, canonical form
cthe CID
vthe version
tthe time Stasho prepared the record (Stasho's clock), always in the exact form JavaScript's Date.prototype.toISOString() produces: YYYY-MM-DDTHH:mm:ss.sssZ, UTC, milliseconds included

Every value is a non-empty string. t is the one field Stasho chooses, and its form is fixed, so every byte of the record is determined by what you asked for plus a time you can bound.

The checks ​

Run all of them on the bytes of message before signing. Any failure: do not sign. Each line names what it stops.

  1. The bytes decode as a legacy message with nothing left over, every length in its shortest encoding. Stops a versioned message, which can pull accounts in from lookup tables you did not decode, and bytes your parser reads differently from the cluster's.
  2. The header requires exactly two signatures. Stops a third signer joining the transaction, and a message in which your key is not a signer at all.
  3. Account 1 is your key. Stops you signing a message built around someone else's key.
  4. Account 0, the fee payer, is not your key. Stops the transaction charging your key's account.
  5. There is exactly one instruction. Stops a second instruction riding on your signature: a token transfer, another memo, anything.
  6. That instruction's program is the memo program (MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr). Stops your signature authorizing a call to any other program.
  7. Its account list is exactly [1], your key alone. Stops another key appearing in the Signed by lines of your record, the fee payer included.
  8. The data is valid UTF-8 and parses as a stasho-vf:1: record with every field a non-empty string and the domain in canonical form. Decode strictly: no replacement characters, no stripping of a leading byte-order mark. Stops you signing a memo that verifiers would not read as a record.
  9. The data is byte-for-byte the canonical encoding of that record. Stops extra keys (say "op":"unbind"), reordered keys, whitespace and alternate escapes that one parser reads differently from another.
  10. The kind is the one you asked for (pub). Stops a transfer or an accept dressed up as a publish.
  11. The domain is yours. Stops your key, which may cover several domains, publishing for one you did not ask for.
  12. The CID and the version are exactly the ones you sent. Stops a compromised Stasho pointing your domain at a different build. (For a transfer: the new authority. For an accept: the transfer signature.)
  13. t is exactly YYYY-MM-DDTHH:mm:ss.sssZ, and within 10 minutes of your own clock. Stops a back-dated or future-dated record, and stops text riding in the one field Stasho picks: lenient date parsers, JavaScript's Date.parse among them, accept other formats and ignore text in parentheses, so a t like Sep 7 2026 12:00 UTC (anything) would otherwise put arbitrary words on a record signed by your key. Compare t to the ISO form of the time it parses to, not just to the clock.

Checks 8 to 12 collapse into one comparison when you already know every field but t: read t from the data, build the exact record you expect with it, and compare bytes. Check 13 then holds t itself to its form and its window. That is what the reference implementation does:

ts
const MEMO = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
const SKEW_MS = 10 * 60_000;

/** Returns `null` when `bytes` is safe to sign for `want`, otherwise why not. */
export function checkPrepared(
  bytes: Uint8Array, signer: PublicKey, want: Expected, now = Date.now(),
): string | null {
  let msg: Message;
  try { msg = Message.from(bytes); } catch { return "not a legacy Solana message"; }
  if (!msg.serialize().equals(bytes)) return "message does not re-encode to the same bytes";
  const [feePayer, customer] = msg.accountKeys;
  if (msg.header.numRequiredSignatures !== 2) return "must require exactly two signatures";
  if (!customer?.equals(signer)) return "account 1 is not my key";
  if (feePayer?.equals(signer)) return "my key is the fee payer";
  if (msg.compiledInstructions.length !== 1) return "must carry exactly one instruction";
  const ix = msg.compiledInstructions[0]!;
  if (!msg.accountKeys[ix.programIdIndex]?.equals(MEMO)) return "program is not SPL Memo";
  const accounts = ix.accountKeyIndexes;
  if (accounts.length !== 1 || accounts[0] !== 1) return "my key must be the only account";
  const prefix = "stasho-vf:1:pub:";
  const memo = Buffer.from(ix.data).toString("utf8");
  let t: unknown;
  try { t = JSON.parse(memo.slice(prefix.length)).t; } catch { t = null; }
  if (typeof t !== "string") return "memo is not a pub record with a timestamp";
  const record = prefix + JSON.stringify({ d: want.domain, c: want.cid, v: want.version, t });
  if (!Buffer.from(record, "utf8").equals(ix.data)) return "memo is not the record I asked for";
  const ms = Date.parse(t);
  if (!Number.isFinite(ms) || new Date(ms).toISOString() !== t) return "timestamp is not ISO 8601";
  if (Math.abs(ms - now) > SKEW_MS) return "timestamp is over 10 minutes off";
  return null;
}

It uses @solana/web3.js only to decode the message. Re-encoding the decoded message and comparing it to the input completes check 1, because the web3.js decoder ignores trailing bytes and accepts over-long length encodings on its own. The byte comparison covers check 8's strict decoding, since a record rebuilt from replacement characters or without a byte-order mark no longer matches the data. It takes want.domain to be canonical already, which the envelope requires anyway, so check 8's domain rule is covered by the same comparison. Check 13's form rule is the toISOString() comparison.

After submit ​

A confirmed transaction with your record on it has, in its log messages:

Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr invoke [1]
Program log: Signed by <your key>
Program log: Memo (len …): "stasho-vf:1:pub:{\"d\":…}"
Program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr success

Fetch it with the Solana getTransaction RPC method, or open it in an explorer, and check that meta.err is null, that the memo invocation succeeded, that it logged Signed by followed by your address, and that its memo is your record. Here is a real one, the first record for stasho.xyz: 5h1qrnm8…p8hnf1.

Read authority only from the Signed by line inside a successful memo invocation, never from which accounts a transaction mentions: anyone can land a transaction that mentions your key next to a forged memo, and it will not carry that line. If the logs contain a Log truncated line, the proof may be missing, so treat the record as unverified rather than valid.

Computing the CID ​

publish records whatever CID you send; Stasho does not check it. For a site Stasho hosts, send the CID Stasho is serving, or compute it from the folder you deployed.

Stasho pins a folder as a UnixFS directory: CIDv1, raw leaves, fixed 262144-byte chunks, balanced layout, a directory sharded (HAMT) once its node would exceed 262144 bytes, no wrapping directory. Every regular file is included, dotfiles too, and symlinks are followed. Empty directories are left out. The CLI's --dir computes the same CID.

kubo produces the same CID with its default import settings plus two flags: --hidden, because kubo otherwise skips dotfiles such as .well-known/, and --dereference-symlinks, because kubo otherwise stores a symlink as a link node instead of the file or folder it points to:

sh
ipfs add -r --hidden --dereference-symlinks --cid-version=1 --raw-leaves --only-hash -Q ./dist

This matches for any folder without an empty subdirectory, symlinks and dotfiles included; kubo keeps an empty directory as a node of its own, so a folder containing one hashes differently. A dangling symlink makes both the CLI and kubo fail; check kubo's exit status, because it prints a partial CID (of one file) before the error. Verified with kubo 0.43.0.

Transfer and accept ​

Both use the same two endpoints, the same envelope, the same message shape and the same checks. Only the record differs.

Transfer ​

Signed by the key bound today, and only while the binding is bound. It asks for the domain to move to a new key; an operator confirms it later.

Prepare body:

json
{"domain":"app.example.com","op":"transfer","newAuthority":"solana:<new key, base58>"}

newAuthority is solana: followed by the new key's base58 ed25519 public key, and must not be the key bound today (400 otherwise). The record Stasho prepares, and the one to check for (check 12 compares newVault to the newAuthority you sent):

stasho-vf:1:transfer:{"d":"<domain>","newVault":"solana:<new key>","t":"<ISO 8601 time>"}

Submit body: {"prepareId":"…","signature":"…"}. The response's signature is the transfer's transaction signature; the new key's holder needs it for the accept.

Accept ​

Signed by the incoming key, both the envelope and the transaction. Only admitted while a transfer is pending, and only with "op":"accept" in the body of both requests.

Prepare body:

json
{"domain":"app.example.com","op":"accept"}

The record, where transfer is the pending transfer's transaction signature:

stasho-vf:1:accept:{"d":"<domain>","transfer":"<transfer signature>","t":"<ISO 8601 time>"}

Check 12 compares transfer to the transfer signature you were given. Without it, all you can do is read it from the prepared record and show it before signing, which is what the CLI does when --transfer is not passed; pin it when you can. A second accept for the same transfer is refused with 409.

Submit body: {"prepareId":"…","signature":"…","op":"accept"}.

Reference client ​

A complete pub client in TypeScript for Node: the envelope, prepare, the checks, the signature, submit and the one retry on 410. Its only dependency is @solana/web3.js, for decoding the message. Stasho's test suite runs it against the CLI's own check on the genuine message and on more than thirty tampered ones, and against the envelope test vector above.

ts
/**
 * Stasho Verified Frontends: a complete `pub` client that runs none of
 * Stasho's code. It builds the request envelope, asks Stasho to prepare the
 * transaction, checks the prepared bytes, signs them, and submits the
 * signature. Specification: https://docs.stasho.xyz/reference/vf-signing-format.html
 *
 * Dependencies: `@solana/web3.js` (only to decode the message) and Node
 * built-ins. Run with Node 22.18 or newer (TypeScript type stripping):
 *
 *   STASHO_VF_KEY=… node reference-client.ts app.example.com bafy… v1.2.3
 *
 * `STASHO_VF_KEY` takes the same formats as the CLI: the 32-byte seed or the
 * 64-byte secret key (seed + public key), in base58 or as a JSON byte array
 * (the `solana-keygen` keypair file).
 */
import { createHash, createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto";
import { pathToFileURL } from "node:url";
import { Message, PublicKey } from "@solana/web3.js";

const API = "https://api.stasho.xyz";
const PREPARE = "/api/vf-cli/publish/prepare";
const SUBMIT = "/api/vf-cli/publish/submit";

export interface Expected { domain: string; cid: string; version: string }
interface Prepared { prepareId: string; message: string }

// #region check
const MEMO = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
const SKEW_MS = 10 * 60_000;

/** Returns `null` when `bytes` is safe to sign for `want`, otherwise why not. */
export function checkPrepared(
  bytes: Uint8Array, signer: PublicKey, want: Expected, now = Date.now(),
): string | null {
  let msg: Message;
  try { msg = Message.from(bytes); } catch { return "not a legacy Solana message"; }
  if (!msg.serialize().equals(bytes)) return "message does not re-encode to the same bytes";
  const [feePayer, customer] = msg.accountKeys;
  if (msg.header.numRequiredSignatures !== 2) return "must require exactly two signatures";
  if (!customer?.equals(signer)) return "account 1 is not my key";
  if (feePayer?.equals(signer)) return "my key is the fee payer";
  if (msg.compiledInstructions.length !== 1) return "must carry exactly one instruction";
  const ix = msg.compiledInstructions[0]!;
  if (!msg.accountKeys[ix.programIdIndex]?.equals(MEMO)) return "program is not SPL Memo";
  const accounts = ix.accountKeyIndexes;
  if (accounts.length !== 1 || accounts[0] !== 1) return "my key must be the only account";
  const prefix = "stasho-vf:1:pub:";
  const memo = Buffer.from(ix.data).toString("utf8");
  let t: unknown;
  try { t = JSON.parse(memo.slice(prefix.length)).t; } catch { t = null; }
  if (typeof t !== "string") return "memo is not a pub record with a timestamp";
  const record = prefix + JSON.stringify({ d: want.domain, c: want.cid, v: want.version, t });
  if (!Buffer.from(record, "utf8").equals(ix.data)) return "memo is not the record I asked for";
  const ms = Date.parse(t);
  if (!Number.isFinite(ms) || new Date(ms).toISOString() !== t) return "timestamp is not ISO 8601";
  if (Math.abs(ms - now) > SKEW_MS) return "timestamp is over 10 minutes off";
  return null;
}
// #endregion check

/** `Authorization: VfKey <base64url message>.<base64url signature>` for one request. */
export function authorization(
  key: KeyObject, path: string, domain: string, body: string, now = Date.now(),
): string {
  const h = createHash("sha256").update(body, "utf8").digest("hex");
  const t = new Date(now).toISOString();
  const message = Buffer.from(
    "stasho-vf:1:auth:" + JSON.stringify({ v: 1, m: "POST", p: path, d: domain, t, h }), "utf8");
  return `VfKey ${message.toString("base64url")}.${sign(null, message, key).toString("base64url")}`;
}

class HttpError extends Error {
  status: number;
  constructor(status: number, text: string) {
    super(`HTTP ${status}: ${text}`);
    this.status = status;
  }
}

async function post<T>(key: KeyObject, path: string, domain: string, payload: object): Promise<T> {
  const body = JSON.stringify(payload);
  const headers = {
    "content-type": "application/json",
    authorization: authorization(key, path, domain, body),
  };
  const res = await fetch(API + path, { method: "POST", headers, body });
  const text = await res.text();
  if (!res.ok) throw new HttpError(res.status, text);
  return JSON.parse(text) as T;
}

type Signer = { key: KeyObject; publicKey: PublicKey };

/** An ed25519 private key from a 32-byte seed (RFC 8410 PKCS#8 wrapping). */
export function keyFromSeed(seed: Uint8Array): Signer {
  const pkcs8 = Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), seed]);
  const key = createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
  const spki = createPublicKey(key).export({ format: "der", type: "spki" });
  return { key, publicKey: new PublicKey(spki.subarray(-32)) };
}

const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

/** Bitcoin-alphabet base58 (Solana keys and signatures), or `null` if it is not base58. */
function base58(text: string): Buffer | null {
  let n = 0n;
  for (const char of text) {
    const digit = B58.indexOf(char);
    if (digit < 0) return null;
    n = n * 58n + BigInt(digit);
  }
  const hex = n === 0n ? "" : n.toString(16);
  const zeros = text.length - text.replace(/^1+/, "").length;
  return Buffer.concat([Buffer.alloc(zeros), Buffer.from(hex.length % 2 ? "0" + hex : hex, "hex")]);
}

const isByte = (b: unknown): boolean =>
  typeof b === "number" && Number.isInteger(b) && b >= 0 && b < 256;

/** Reads `STASHO_VF_KEY`. No error message includes the key, even a malformed one. */
export function readKey(raw: string | undefined): Signer {
  const text = raw?.trim() ?? "";
  if (text === "") throw new Error("STASHO_VF_KEY is not set");
  let bytes: Buffer | null = null;
  if (text.startsWith("[")) {
    try {
      const list: unknown = JSON.parse(text);
      if (Array.isArray(list) && list.every(isByte)) bytes = Buffer.from(list as number[]);
    } catch {
      bytes = null; // Not rethrown: JSON.parse's own message quotes the input.
    }
  } else {
    bytes = base58(text);
  }
  if (bytes === null || (bytes.length !== 32 && bytes.length !== 64)) {
    throw new Error("STASHO_VF_KEY must be a 32- or 64-byte key, in base58 or a JSON byte array");
  }
  const signer = keyFromSeed(bytes.subarray(0, 32));
  if (bytes.length === 64 && !signer.publicKey.toBuffer().equals(bytes.subarray(32))) {
    throw new Error("STASHO_VF_KEY is a 64-byte key whose public half is not its own");
  }
  return signer;
}

/** Prepare, check, sign, submit. Returns the landed transaction signature. */
export async function publish({ key, publicKey }: Signer, want: Expected): Promise<string> {
  for (let attempt = 0; ; attempt++) {
    const prep = await post<Prepared>(key, PREPARE, want.domain, { ...want });
    const bytes = Buffer.from(prep.message, "base64");
    const refusal = checkPrepared(bytes, publicKey, want);
    if (refusal !== null) throw new Error("refusing to sign: " + refusal);
    const signature = sign(null, bytes, key).toString("base64");
    let landed: { signature?: unknown };
    try {
      landed = await post(key, SUBMIT, want.domain, { prepareId: prep.prepareId, signature });
    } catch (err) {
      if (attempt > 0 || !(err instanceof HttpError) || err.status !== 410) throw err;
      continue; // 410: the prepared transaction expired. Prepare again, once.
    }
    const txid = landed.signature;
    if (typeof txid !== "string" || base58(txid)?.length !== 64) {
      throw new Error("submit did not return a transaction signature");
    }
    return txid;
  }
}

if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [domain, cid, version = "0"] = process.argv.slice(2);
  if (!domain || !cid) throw new Error("usage: reference-client.ts <domain> <cid> [version]");
  const signature = await publish(readKey(process.env["STASHO_VF_KEY"]), { domain, cid, version });
  console.log(`https://explorer.solana.com/tx/${signature}`);
}