Skip to content
SIGNET
[ Quickstart ]

One minute, start to finish.

You need a browser. A wallet makes the receipt yours; without one the notary signs it instead. Both paths end with the same line of text and the same check.

[ Step 01 ]

Seal it in the page.

Open the sealer, paste anything into the box and watch the fingerprint appear underneath. That hash is computed by your browser; the box never sends what you typed.

Connect a wallet and press Seal with my wallet. The wallet shows you an EIP-191 signing prompt. Nothing is broadcast and no gas is spent, because a personal_sign never touches a chain.

You get one line back. Copy it. That line is the whole receipt: payload and signature, nothing missing.

the digest, in any runtime
// The digest the receipt commits to. Node 18+, browsers, Deno, all the same.
const bytes = new TextEncoder().encode(content);
const buf = await crypto.subtle.digest("SHA-256", bytes);
const hex = [...new Uint8Array(buf)]
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

console.log("sha256:" + hex);
[ Step 02 ]

Check it without us.

Paste the line into the verifier, or run this and never open the site again.

verify.ts
import { recoverMessageAddress } from "viem";

// One sealed line, exactly as it was handed to you.
const line = "sgn3.<payload>.<signature>";
const [, payload, sig] = line.split(".");

const receipt = JSON.parse(
  Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(),
);

// Rebuild the text the wallet displayed. Every line below comes out of the
// receipt, so the readable part can never disagree with the signed part.
const message = [
  "Signet receipt v3",
  "trysignet.xyz",
  "",
  `fingerprint ${receipt.fingerprint}`,
  `issued      ${receipt.issued_at}`,
  `chain       ${receipt.chain_id}`,
  receipt.prev
    ? `follows     ${receipt.prev}`
    : "follows     nothing, this starts a run",
  "",
  "Signing this proves the key above saw this fingerprint.",
  "It says nothing about whether the work behind it was any good.",
  "",
  payload,
].join("\n");

// Some signers emit v as 0 or 1. Normalize, or a good signature reads as fake.
const v = parseInt(sig.slice(130, 132), 16);
const signature = v < 27 ? sig.slice(0, 130) + (v + 27).toString(16) : sig;

const recovered = await recoverMessageAddress({ message, signature });

// The receipt names its own signer. Compare, and only then believe it.
const ok = recovered.toLowerCase() === receipt.issuer.toLowerCase();
console.log(ok ? "signed by the named key" : "different key, do not trust it");

A recovered address is not a result. secp256k1 will hand you an address for almost any 65 bytes you give it, so the line that matters is the comparison against the issuer the receipt names. Skip it and you have written a function that approves everything.

[ Step 03 ]

Or have the notary sign it.

Hash on your side, post the digest, get a signature from a key you do not hold. The route never receives the content and has nowhere to put it if it did.

shell
curl -s "$SIGNET/api/notary" \
  -H 'content-type: application/json' \
  -d '{
    "fingerprint": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "bytes": 4,
    "label": "the four bytes test"
  }'

# $SIGNET is wherever this site is running. On this page it is the
# address in your URL bar, so a relative /api/notary works in a browser.

The response carries envelope, the same sgn3 line the page produces, plus the decoded receipt for convenience. Pin the signer address from the signers page and check the envelope against it.

The digest in the example above is the SHA-256 of the four bytes test. Hash something of your own and the route will sign that instead.