farcaster.js

raw

#!/usr/bin/env node
/*
 * Register a Farcaster identity from this machine.
 *   node farcaster.js check     - read-only: balances, price, whether we already have an fid
 *   node farcaster.js register  - step 1: buy the fid (spends ETH)
 *   node farcaster.js signer    - step 2: add an ed25519 signer key so we can actually cast
 *
 * Every write step is idempotent: it checks on-chain state first and bails if already done.
 */
const { JsonRpcProvider, Wallet, Contract, formatEther, parseEther, hexlify } = require("ethers");
const crypto = require("crypto");
const fs = require("fs");

const RPCS = ["https://mainnet.optimism.io", "https://optimism.publicnode.com", "https://op-pokt.nodies.app"];
const OUT = __dirname + "/farcaster.json";

const ID_GATEWAY   = "0x00000000Fc25870C6eD6b6c7E41Fb078b7656f69";
const ID_REGISTRY  = "0x00000000Fc6c5F01Fc30151999387Bb99A9f489b";
const KEY_GATEWAY  = "0x00000000fC56947c7E7183f8Ca4B62398CaAdf0B";
const SKR_VALIDATOR= "0x00000000FC700472606ED4fA22623Acf62c60553";

const idGatewayAbi = [
  "function price() view returns (uint256)",
  "function register(address recovery) payable returns (uint256 fid, uint256 overpayment)",
];
const idRegistryAbi = ["function idOf(address) view returns (uint256)"];
const keyGatewayAbi = ["function add(uint32 keyType, bytes key, uint8 metadataType, bytes metadata)"];
const skrAbi = [
  "function encodeMetadata((uint256 requestFid, address requestSigner, bytes signature, uint256 deadline)) view returns (bytes)",
];

async function provider() {
  let last;
  for (const url of RPCS) {
    try { const p = new JsonRpcProvider(url); await p.getBlockNumber(); return p; }
    catch (e) { last = e; }
  }
  throw last;
}

function loadState() {
  try { return JSON.parse(fs.readFileSync(OUT, "utf8")); } catch { return {}; }
}
function saveState(s) {
  fs.writeFileSync(OUT, JSON.stringify(s, null, 2));
  fs.chmodSync(OUT, 0o600);
}

async function main() {
  const cmd = process.argv[2] || "check";
  const keys = JSON.parse(fs.readFileSync(__dirname + "/keys.json", "utf8"));
  const p = await provider();
  const w = new Wallet(keys.privateKey, p);

  const idGateway = new Contract(ID_GATEWAY, idGatewayAbi, w);
  const idRegistry = new Contract(ID_REGISTRY, idRegistryAbi, p);

  const bal = await p.getBalance(w.address);
  const price = await idGateway["price()"]();
  const existing = await idRegistry.idOf(w.address);
  const state = loadState();

  console.log("address :", w.address);
  console.log("balance :", formatEther(bal), "ETH on Optimism");
  console.log("fid cost:", formatEther(price), "ETH");
  console.log("fid     :", existing.toString() === "0" ? "(none yet)" : existing.toString());

  if (cmd === "check") {
    const need = price + parseEther("0.00005");            // + headroom for gas
    console.log(bal >= need ? "\nREADY: enough ETH to register." :
      `\nWAITING: need ~${formatEther(need)} ETH, short by ${formatEther(need - bal)}.`);
    return;
  }

  if (cmd === "register") {
    if (existing.toString() !== "0") { console.log("\nAlready registered. Nothing to do."); return; }
    if (bal < price) throw new Error("not enough ETH to cover the registration price");
    // recovery address = self. No third party can claw the account back.
    const tx = await idGateway.register(w.address, { value: price });
    console.log("\nsent:", tx.hash, "— waiting for confirmation…");
    const rc = await tx.wait();
    const fid = await idRegistry.idOf(w.address);
    console.log("CONFIRMED in block", rc.blockNumber, "→ FID", fid.toString());
    saveState({ ...state, fid: fid.toString(), custody: w.address, registerTx: tx.hash });
    return;
  }

  if (cmd === "signer") {
    const fid = existing;
    if (fid.toString() === "0") throw new Error("register an fid first");
    if (state.signerPublicKey) { console.log("\nSigner already added:", state.signerPublicKey); return; }

    // ed25519 keypair — this is what actually signs casts
    const kp = crypto.generateKeyPairSync("ed25519");
    const der = kp.publicKey.export({ type: "spki", format: "der" });
    const pub = hexlify(der.subarray(der.length - 32));            // raw 32-byte pubkey
    const priv = kp.privateKey.export({ type: "pkcs8", format: "der" });

    const deadline = Math.floor(Date.now() / 1000) + 3600;
    // Self-signed: we are our own "app", which is allowed since we control the custody key.
    const sig = await w.signTypedData(
      { name: "Farcaster SignedKeyRequestValidator", version: "1",
        chainId: 10, verifyingContract: SKR_VALIDATOR },
      { SignedKeyRequest: [
          { name: "requestFid", type: "uint256" },
          { name: "key", type: "bytes" },
          { name: "deadline", type: "uint256" } ] },
      { requestFid: fid, key: pub, deadline }
    );

    // encodeMetadata must come from the validator contract; hand-rolled ABI encoding
    // gets the dynamic offset pointer wrong.
    const skr = new Contract(SKR_VALIDATOR, skrAbi, p);
    const metadata = await skr.encodeMetadata({
      requestFid: fid, requestSigner: w.address, signature: sig, deadline });

    const keyGateway = new Contract(KEY_GATEWAY, keyGatewayAbi, w);
    const tx = await keyGateway.add(1, pub, 1, metadata);
    console.log("\nsent:", tx.hash, "— waiting…");
    const rc = await tx.wait();
    console.log("CONFIRMED in block", rc.blockNumber);
    saveState({ ...state, fid: fid.toString(), custody: w.address,
                signerPublicKey: pub, signerPrivateKeyPkcs8: priv.toString("base64"),
                signerTx: tx.hash });
    console.log("signer public key:", pub);
    return;
  }

  throw new Error("unknown command: " + cmd);
}

main().catch(e => { console.error("ERROR:", e.shortMessage || e.message); process.exit(1); });