// Gateway quickstart: create a user, fund their wallet, buy
// 2 USDC of SOL for them, and find the fill in your organization's
// transactions.
//
//   npm install tsx @turnkey/api-key-stamper @turnkey/crypto
//   TM_KEY_FILE=path/to/your-api-key.json npx tsx quickstart.ts
//
// This trades real funds. Running it again reuses the same user
// and buys another 2 USDC of SOL while the wallet has funds.
import { ApiKeyStamper } from "@turnkey/api-key-stamper";
import { generateP256KeyPair } from "@turnkey/crypto";
import { createPrivateKey, sign } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";

const API = "https://api.truemarkets.co";
const API_KEY_FILE = process.env.TM_KEY_FILE!;
const SIGNER_KEY_FILE =
  process.env.SIGNER_KEY_FILE ?? "signer-key.json";
// Your own id for this customer.
const EXTERNAL_REF_ID = "quickstart_user_1";
const ORDER_USDC = "2";

let token = "";
const sleep = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

// Every call sends the organization token. Calls for one user
// add TM-On-Behalf-Of.
async function post(
  path: string,
  body: unknown,
  headers: Record<string, string> = {},
) {
  return call("POST", path, body, headers);
}

async function get(
  path: string,
  headers: Record<string, string> = {},
) {
  return call("GET", path, undefined, headers);
}

async function call(
  method: string,
  path: string,
  body: unknown,
  headers: Record<string, string>,
) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: {
      "Content-Type": "application/json",
      ...(token && { Authorization: `Bearer ${token}` }),
      ...headers,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const reason = json.code ?? json.type ?? "";
    const detail = `${res.status} ${reason} ${json.message ?? ""}`;
    const err = new Error(`${method} ${path}: ${detail}`);
    Object.assign(err, { status: res.status, code: json.code });
    throw err;
  }
  return json;
}

// #region signer
// The signer key signs every wallet transaction for the users you
// register it on. Keep this file: a user can only ever sign with
// the key it was created with.
if (!existsSync(SIGNER_KEY_FILE)) {
  const { publicKey, privateKey } = generateP256KeyPair();
  const file = {
    signer_public_key: publicKey,
    signer_private_key: privateKey,
  };
  writeFileSync(SIGNER_KEY_FILE, JSON.stringify(file), {
    mode: 0o600,
  });
}
const signer = JSON.parse(readFileSync(SIGNER_KEY_FILE, "utf8"));
const stamper = new ApiKeyStamper({
  apiPublicKey: signer.signer_public_key,
  apiPrivateKey: signer.signer_private_key,
});
const stamp = async (payload: string) =>
  (await stamper.stamp(payload)).stampHeaderValue;
// #endregion signer

async function main() {
  // #region assets
  const { data: assets } = await get("/v1/gateway/assets");
  const sol = assets.find(
    (a: any) => a.symbol === "SOL" && a.chain === "solana",
  );
  // #endregion assets

  // #region token
  const { key_id, private_key } = JSON.parse(
    readFileSync(API_KEY_FILE, "utf8"),
  );
  const timestamp = Math.floor(Date.now() / 1000);
  const signature = sign(
    "sha256",
    Buffer.from(`${key_id}.${timestamp}`),
    {
      key: createPrivateKey({ key: private_key, format: "jwk" }),
      // r and s concatenated, which the API expects
      dsaEncoding: "ieee-p1363",
    },
  ).toString("base64url");

  const minted = await post("/v1/auth/api-key/token", {
    key_id,
    timestamp,
    signature,
  });
  token = minted.access_token;

  // The token names your organization: no id to configure.
  const claims = JSON.parse(
    Buffer.from(token.split(".")[1], "base64url").toString(),
  );
  const organizationId = claims.tm.organization_id;
  // #endregion token
  console.log(`✓ token     expires ${minted.expires_in}`);

  // #region user
  let user;
  for (let attempt = 1; ; attempt++) {
    try {
      user = await post(
        `/v1/account/organizations/${organizationId}/users`,
        {
          external_ref_id: EXTERNAL_REF_ID,
          signer_public_key: signer.signer_public_key,
        },
      );
      if (user.wallets.length) break;
    } catch (err: any) {
      // A 503 means the wallets aren't ready yet. The same
      // request finishes them.
      if (err.status !== 503 || attempt === 5) throw err;
    }
    await sleep(2000);
  }
  const userId = user.user_id;
  const forUser = { "TM-On-Behalf-Of": userId };
  const solanaAddress = user.wallets.find(
    (w: any) => w.chain_family === "solana",
  ).address;
  // #endregion user
  console.log(`✓ user      ${userId}   solana ${solanaAddress}`);

  // #region fund
  const usdcAvailable = async () => {
    const { data } = await get("/v1/gateway/balances", forUser);
    const usdc = data.find(
      (b: any) => b.symbol === "USDC" && b.chain === "solana",
    );
    return Number(usdc?.available ?? 0);
  };
  if ((await usdcAvailable()) < Number(ORDER_USDC)) {
    console.log(
      `… send about 3 USDC on Solana to ${solanaAddress}`,
    );
    while ((await usdcAvailable()) < Number(ORDER_USDC))
      await sleep(10_000);
  }
  // #endregion fund
  console.log("✓ funded");

  // #region order
  const order = await post(
    "/v1/gateway/orders",
    {
      asset_id: sol.id,
      qty: ORDER_USDC,
      qty_unit: "quote",
      side: "buy",
      type: "market",
    },
    forUser,
  );
  // An empty order_id means no order was created, and
  // quote.issues says why.
  if (!order.order_id)
    throw new Error(
      `no order: ${JSON.stringify(order.quote?.issues)}`,
    );

  // Sign each payload and execute straight away, before the
  // quote expires.
  const signatures = await Promise.all(
    order.payloads.map((p: any) => stamp(p.payload)),
  );
  const executed = await post(
    `/v1/gateway/orders/${order.order_id}/execute`,
    { signatures, auth_type: "api_key" },
    forUser,
  );
  // #endregion order

  // #region fill
  let filled = executed;
  while (
    !["complete", "canceled", "failed"].includes(filled.status)
  ) {
    await sleep(2000);
    filled = await get(
      `/v1/gateway/orders/${order.order_id}`,
      forUser,
    );
  }
  console.log(
    `✓ order     ${filled.status}  ` +
      `${filled.executed_qty ?? ""} SOL for ${ORDER_USDC} USDC`,
  );

  // Your organization's transactions cover every user, so this
  // call has no header.
  const feed = await get(
    `/v1/gateway/organizations/${organizationId}/transactions` +
      "?type=order",
  );
  const row = feed.data.find((t: any) => t.id === order.order_id);
  // #endregion fill
  console.log(
    row
      ? `✓ in your organization's transactions (${row.status})`
      : "… not in the transactions list yet",
  );
}

main().catch((err) => {
  console.error(`✗ ${err.message}`);
  process.exit(1);
});
