Skip to content
On this page

Docs

tougue. — a Safe fork for KUB Chain

The official, unmodified Safe contracts with an independent interface (no Safe{Wallet} code). New Vaults use Safe 1.5.0, source-verified on KUB Scan. Requests, approvals and execution all live on-chain; there is no backend. Creating a Vault is free: you pay only gas.

How a Vault works on KUB Chain (formerly Bitkub Chain), the SDK to send a request to a Safe Vault on KUB, and every contract address. New here? What is tougue.?

KUB Chain · ID 96Safe 1.5.0 1.5.0 source verified on KUB Scan

Not affiliated with or endorsed by Safe Ecosystem Foundation or Safe Labs GmbH. Safe is a trademark of its owner.

How a Vault works on KUB Chain

1ProposeOwnerapproveHash(h) + payload2ApproveOther ownersapproveHash(h)3ExecuteAnyoneexecTransaction(…, v=1 sigs)✓Vault callsThe targetto.call(data)
h = safeTxHash. Steps 1–3 are on-chain transactions sent to the Vault.
  • On-chain queue

    A request is an owner's approveHash with the SafeTx appended. tougue. finds it from the Vault's events and re-hashes it.

  • Irrevocable approvals

    An approval cannot be withdrawn. To cancel, owners approve a rejection at the same nonce.

  • Legacy transactions

    KUB has no EIP-1559. Every transaction is type 0 with an explicit gasPrice.

  • Local names

    Vault and address names stay in your browser. Nothing personal goes on-chain.

Build on tougue.

Put your protocol under a multisig.

Govern contract ownership, run your dApp's admin and protect your team's funds — from one Safe Vault on KUB.

  • Contract ownership
  • dApp admin
  • Team treasury
  • DAO payouts

One Markdown file has everything — contracts, a ready-to-run TypeScript helper, request links and the rules.

Any dApp, any protocol, any tool: give this file to your team or your AI agent and ship.

tougue-integration.md49 KB · 1,182 lines
  • Chain facts
  • Contracts
  • Path A / B
  • viem helper
  • Rules
  • Any ERC-20
Vault
A Safe proxy on KUB. Holds assets and calls contracts.
Owner
An address in getOwners(). Only owners approve.
Threshold
Approvals needed to execute (getThreshold()).
Nonce
The Vault's counter (nonce()). Each nonce executes once, in order.
SafeTx
to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce
safeTxHash
EIP-712 hash of the SafeTx, domain { chainId: 96, verifyingContract: vault }. Owners approve this hash.
Request
An owner's approveHash(safeTxHash) with the SafeTx appended. It is also that owner's approval.
  1. 1Read the Vault: VERSION, nonce, getThreshold, getOwners. Your signer must be an owner and an EOA that sends the transaction to the Vault itself. A smart-account owner uses Path B.
  2. 2Pick the next free nonce, after every pending request (pass fromBlock).
  3. 3Build the SafeTx: a CALL with every gas and refund field zero.
  4. 4Hash it twice, offline (EIP-712) and with the Vault's getTransactionHash. They must match.
  5. 5Encode approveHash(safeTxHash) ‖ 0x544f554e47 ‖ 0x01 ‖ abi.encode(…).
  6. 6Simulate the call as the Vault (simulateAndRevert + SimulateTxAccessor, an eth_call). The helper refuses to publish a call that reverts.
  7. 7Estimate, then send approveHash to the Vault as a legacy transaction with an explicit gasPrice.
  8. 8Check the receipt: from you, to the Vault, with ApproveHash(safeTxHash, you). Then send the other owners the request link.
path-a.ts
import {
  createPublicClient,
  createWalletClient,
  custom,
  encodeFunctionData,
  erc20Abi,
  http,
  parseUnits,
} from "viem";
import type { Address, EIP1193Provider } from "viem";
import { kub, sendRequest } from "./tougue-request";

// Replace these with your own values.
declare const ethereum: EIP1193Provider; // the user's injected wallet (MetaMask, OKX, Rabby…)
declare const VAULT: Address; // the Vault
declare const VAULT_CREATED_AT_BLOCK: bigint; // its creation block (KUB Scan), or 0n
declare const TOKEN: Address; // any ERC-20 on KUB
declare const RECIPIENT: Address;

const publicClient = createPublicClient({ chain: kub, transport: http() });
const [account] = await ethereum.request({ method: "eth_requestAccounts" });
if (!account) throw new Error("no account");
const walletClient = createWalletClient({ account, chain: kub, transport: custom(ethereum) });
// Put the wallet on KUB first (adds the network if it is missing).
await walletClient.switchChain({ id: kub.id }).catch(() => walletClient.addChain({ chain: kub }));

// The call the Vault will make once owners approve it: any contract, any function.
const data = encodeFunctionData({
  abi: erc20Abi,
  functionName: "transfer",
  args: [RECIPIENT, parseUnits("100", 18)], // match the token's decimals()
});

const request = await sendRequest({
  publicClient,
  walletClient,
  vault: VAULT,
  call: { to: TOKEN, data },
  note: "Pay invoice #1234", // public forever
  fromBlock: VAULT_CREATED_AT_BLOCK, // next free nonce: queues behind requests already on-chain
});

// Send the other owners here to approve; anyone can execute at threshold.
console.log(request.url);
request link
https://tougue.xyz/vault/tx/?safe=kub:<vault>&hash=<safeTxHash>
wire format (v1): calldata layout
approveHash(bytes32)  0xd4d9bdcd ‖ safeTxHash                        36 bytes
magic ‖ version       0x544f554e47 ("TOUNG") ‖ 0x01                  6 bytes
payload               abi.encode(                                   11 params, not a tuple
                        address to, uint256 value, bytes data, uint8 operation,
                        uint256 safeTxGas, uint256 baseGas, uint256 gasPrice,
                        address gasToken, address refundReceiver, uint256 nonce,
                        string note)

Standard ABI encoding (viem, ethers, Solidity abi.encode). tougue. decodes, re-encodes and requires byte equality, then checks that the payload hashes to the approved hash for this Vault and chain. Anything else is ignored.

Inside sendRequest(): SafeTx, hashing, encoding
build the SafeTx
/** The 10 fields Safe signs. tougue. requires safeTxGas = baseGas = gasPrice = 0 and zero refund addresses. */
export interface SafeTx {
  to: Address;
  value: bigint;
  data: Hex;
  operation: 0 | 1; // 0 = CALL, 1 = DELEGATECALL (batches: MultiSendCallOnly only)
  safeTxGas: bigint;
  baseGas: bigint;
  gasPrice: bigint;
  gasToken: Address;
  refundReceiver: Address;
  nonce: bigint;
}

export interface Call {
  to: Address;
  value?: bigint;
  data?: Hex;
  operation?: 0 | 1;
}

function checkDataSize(data: Hex): void {
  if (size(data) > MAX_DATA_BYTES)
    throw new Error(`data is ${size(data)} bytes; tougue. accepts at most ${MAX_DATA_BYTES}`);
}

function checkNote(note: string): void {
  if (new TextEncoder().encode(note).length > MAX_NOTE_BYTES)
    throw new Error(`note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`);
}

export function buildSafeTx(call: Call, nonce: bigint): SafeTx {
  const operation = call.operation ?? 0;
  const to = getAddress(call.to);
  if (operation === 1 && !Object.values(MULTI_SEND_CALL_ONLY).some((a) => isAddressEqual(a, to)))
    throw new Error("DELEGATECALL is allowed only to Safe's MultiSendCallOnly");
  const data = call.data ?? "0x";
  checkDataSize(data);
  return {
    to,
    value: call.value ?? 0n,
    data,
    operation,
    safeTxGas: 0n,
    baseGas: 0n,
    gasPrice: 0n,
    gasToken: zeroAddress,
    refundReceiver: zeroAddress,
    nonce,
  };
}
safeTxHash, offline and on-chain
const SAFE_TX_TYPES = {
  SafeTx: [
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "data", type: "bytes" },
    { name: "operation", type: "uint8" },
    { name: "safeTxGas", type: "uint256" },
    { name: "baseGas", type: "uint256" },
    { name: "gasPrice", type: "uint256" },
    { name: "gasToken", type: "address" },
    { name: "refundReceiver", type: "address" },
    { name: "nonce", type: "uint256" },
  ],
} as const;

/** Offline EIP-712 hash. Domain = { chainId, verifyingContract: vault } (Safe 1.3.0+). */
export function hashSafeTx(chainId: number, vault: Address, tx: SafeTx): Hash {
  return hashTypedData({
    domain: { chainId, verifyingContract: getAddress(vault) },
    types: SAFE_TX_TYPES,
    primaryType: "SafeTx",
    message: tx,
  });
}

/** The Vault's own hash (eth_call). Must equal hashSafeTx(); sendRequest() checks both. */
export function readSafeTxHash(client: PublicClient, vault: Address, tx: SafeTx): Promise<Hash> {
  return client.readContract({
    address: vault,
    abi: SAFE_ABI,
    functionName: "getTransactionHash",
    args: [
      tx.to,
      tx.value,
      tx.data,
      tx.operation,
      tx.safeTxGas,
      tx.baseGas,
      tx.gasPrice,
      tx.gasToken,
      tx.refundReceiver,
      tx.nonce,
    ],
  });
}
request calldata
const PAYLOAD_V1 = [
  { type: "address", name: "to" },
  { type: "uint256", name: "value" },
  { type: "bytes", name: "data" },
  { type: "uint8", name: "operation" },
  { type: "uint256", name: "safeTxGas" },
  { type: "uint256", name: "baseGas" },
  { type: "uint256", name: "gasPrice" },
  { type: "address", name: "gasToken" },
  { type: "address", name: "refundReceiver" },
  { type: "uint256", name: "nonce" },
  { type: "string", name: "note" },
] as const;

/**
 * approveHash(safeTxHash) ‖ 0x544f554e47 ‖ 0x01 ‖ abi.encode(...11 params, NOT a tuple).
 * The note is public forever and is not covered by the safeTxHash. Max 280 UTF-8 bytes; data max 128 KiB.
 */
export function encodeRequestCalldata(safeTxHash: Hash, tx: SafeTx, note = ""): Hex {
  checkNote(note);
  checkDataSize(tx.data);
  const approve = encodeFunctionData({
    abi: SAFE_ABI,
    functionName: "approveHash",
    args: [safeTxHash],
  });
  const payload = encodeAbiParameters(PAYLOAD_V1, [
    tx.to,
    tx.value,
    tx.data,
    tx.operation,
    tx.safeTxGas,
    tx.baseGas,
    tx.gasPrice,
    tx.gasToken,
    tx.refundReceiver,
    tx.nonce,
    note,
  ]);
  return concat([approve, REQUEST_PREFIX_V1, payload]);
}

const REQUEST_HEAD_BYTES = 4 + 32 + 6; // approveHash selector + safeTxHash + prefix

/** The reverse of encodeRequestCalldata(). Returns null for anything that is not a canonical v1 request. */
export function decodeRequestCalldata(
  input: Hex,
): { safeTxHash: Hash; tx: SafeTx; note: string } | null {
  try {
    if (size(input) < REQUEST_HEAD_BYTES + 11 * 32) return null;
    if (slice(input, 0, 4).toLowerCase() !== "0xd4d9bdcd") return null; // approveHash(bytes32)
    if (slice(input, 36, REQUEST_HEAD_BYTES).toLowerCase() !== REQUEST_PREFIX_V1) return null;
    const body = slice(input, REQUEST_HEAD_BYTES);
    const v = decodeAbiParameters(PAYLOAD_V1, body);
    // Like tougue.: the payload must re-encode to the same bytes.
    if (encodeAbiParameters(PAYLOAD_V1, v).toLowerCase() !== body.toLowerCase()) return null;
    const [to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver] = v;
    if (operation !== 0 && operation !== 1) return null;
    const tx: SafeTx = {
      to: getAddress(to),
      value,
      data,
      operation,
      safeTxGas,
      baseGas,
      gasPrice,
      gasToken: getAddress(gasToken),
      refundReceiver: getAddress(refundReceiver),
      nonce: v[9],
    };
    return { safeTxHash: slice(input, 4, 36), tx, note: v[10] };
  } catch {
    return null;
  }
}
simulateRequest()
export type Simulation =
  | { status: "success"; gasUsed: bigint }
  | { status: "reverted"; returnData: Hex }
  /** The RPC could not run the simulation. tougue. simulates again when owners review. */
  | { status: "unavailable"; reason: string };

function revertData(err: unknown): Hex | undefined {
  for (let e: unknown = err, i = 0; e && typeof e === "object" && i < 8; i++) {
    const d = (e as { data?: unknown }).data;
    if (typeof d === "string" && d.startsWith("0x")) return d as Hex;
    const inner = d && typeof d === "object" ? (d as { data?: unknown }).data : undefined;
    if (typeof inner === "string" && inner.startsWith("0x")) return inner as Hex;
    e = (e as { cause?: unknown }).cause;
  }
  return undefined;
}

/**
 * Runs the SafeTx's call exactly as the Vault would (Safe's simulateAndRevert + SimulateTxAccessor, eth_call only;
 * nothing is sent). Use it before publishing: approvals are irrevocable, so a broken call wastes everyone's gas.
 */
export async function simulateRequest(
  client: PublicClient,
  vault: Address,
  tx: SafeTx,
  version: SafeVersion,
): Promise<Simulation> {
  const data = encodeFunctionData({
    abi: SAFE_ABI,
    functionName: "simulateAndRevert",
    args: [
      SIMULATE_TX_ACCESSOR[version],
      encodeFunctionData({
        abi: SIMULATE_ABI,
        functionName: "simulate",
        args: [tx.to, tx.value, tx.data, tx.operation],
      }),
    ],
  });
  let out: Hex | undefined;
  try {
    await client.call({ account: vault, to: vault, data });
    return { status: "unavailable", reason: "simulateAndRevert did not revert" };
  } catch (e) {
    out = revertData(e);
  }
  // Revert data = uint256 1 ‖ uint256 length ‖ abi.encode(uint256 gasUsed, bool success, bytes returnData).
  if (!out || size(out) < 64 || hexToBigInt(slice(out, 0, 32)) !== 1n)
    return { status: "unavailable", reason: "no simulation result from the RPC" };
  try {
    const [gasUsed, success, returnData] = decodeAbiParameters(
      [{ type: "uint256" }, { type: "bool" }, { type: "bytes" }],
      slice(out, 64),
    );
    return success ? { status: "success", gasUsed } : { status: "reverted", returnData };
  } catch {
    return { status: "unavailable", reason: "malformed simulation result" };
  }
}
sendRequest()
/** Pick the request's nonce. `fromBlock` (recommended) queues it behind every request already on-chain. */
export type NonceChoice =
  { fromBlock: bigint; nonce?: undefined } | { nonce: bigint; fromBlock?: undefined };

async function chooseNonce(
  client: PublicClient,
  vault: Address,
  info: VaultInfo,
  c: NonceChoice,
): Promise<bigint> {
  const nonce = c.nonce ?? (await nextFreeNonce(client, vault, { fromBlock: c.fromBlock }));
  if (nonce < info.nonce) throw new Error(`nonce ${nonce} is already used`);
  return nonce;
}

async function checkSimulation(
  client: PublicClient,
  vault: Address,
  tx: SafeTx,
  version: SafeVersion,
): Promise<Simulation> {
  const sim = await simulateRequest(client, vault, tx, version);
  if (sim.status === "reverted")
    throw new Error(
      `the call reverts when the Vault runs it (${sim.returnData}). Fix it, or pass simulate: false ` +
        "if it only works after an earlier pending request executes",
    );
  return sim;
}

export interface SentRequest {
  safeTxHash: Hash;
  nonce: bigint;
  /** The SafeTx owners approve; pass it to encodeExecute() at threshold. */
  tx: SafeTx;
  txHash: Hash;
  blockNumber: bigint;
  threshold: bigint;
  /** The pre-publish simulation of the call ("unavailable" if the RPC could not run it). */
  simulation: Simulation | null;
  /** Where owners approve and anyone executes. null on a chain tougue. has no prefix for. */
  url: string | null;
}

/**
 * Path A: the wallet IS a Vault owner and an EOA (a normal wallet account) that sends the transaction to the Vault
 * itself. Publishes the request on-chain (about 50k gas). A smart-account owner must use Path B: tougue. only reads
 * requests sent by the owner directly to the Vault. `chainId` defaults to KUB (96); pass another to test on a fork
 * or devnet with the official Safe contracts.
 */
export async function sendRequest(
  p: {
    publicClient: PublicClient;
    walletClient: WalletClient;
    vault: Address;
    call: Call;
    note?: string;
    chainId?: number;
    /** Default true: refuse to publish a call that reverts in simulation. */
    simulate?: boolean;
  } & NonceChoice,
): Promise<SentRequest> {
  const chainId = p.chainId ?? KUB_CHAIN_ID;
  const account = p.walletClient.account;
  if (!account) throw new Error("walletClient has no account");
  if ((await p.publicClient.getChainId()) !== chainId) throw new Error("wrong network");
  const vault = getAddress(p.vault);

  const info = await readVault(p.publicClient, vault);
  if (!info.owners.some((o) => isAddressEqual(o, account.address)))
    throw new Error("Not a Vault owner: use Path B (buildOwnerRequest)");
  const code = await p.publicClient.getCode({ address: account.address });
  // A contract owner (another Safe, an AA wallet) cannot send the request itself. 0xef0100… = EIP-7702 EOA.
  if (code && code !== "0x" && !code.toLowerCase().startsWith("0xef0100"))
    throw new Error("The owner is a smart contract; Path A needs an EOA owner: use Path B");
  const nonce = await chooseNonce(p.publicClient, vault, info, p);

  const tx = buildSafeTx(p.call, nonce);
  if (tx.operation === 1 && !isAddressEqual(tx.to, MULTI_SEND_CALL_ONLY[info.version]))
    throw new Error(`Batches must use MultiSendCallOnly ${info.version}`);

  const safeTxHash = hashSafeTx(chainId, vault, tx);
  const onChain = await readSafeTxHash(p.publicClient, vault, tx);
  if (onChain.toLowerCase() !== safeTxHash.toLowerCase()) throw new Error("safeTxHash mismatch");
  const simulation =
    p.simulate === false ? null : await checkSimulation(p.publicClient, vault, tx, info.version);

  const data = encodeRequestCalldata(safeTxHash, tx, p.note);
  // Estimates the approveHash transaction as the owner (a non-owner reverts with GS030).
  const gas = await p.publicClient.estimateGas({ account: account.address, to: vault, data });
  const gasPrice = await p.publicClient.getGasPrice();
  // KUB has no EIP-1559: always a legacy (type 0) transaction with an explicit gasPrice.
  const txHash = await p.walletClient.sendTransaction({
    account,
    chain: p.walletClient.chain,
    to: vault,
    data,
    value: 0n,
    type: "legacy",
    gas: (gas * 12n) / 10n,
    gasPrice,
  });
  const receipt = await p.publicClient.waitForTransactionReceipt({ hash: txHash });
  if (receipt.status !== "success") throw new Error(`request transaction ${txHash} reverted`);
  // tougue. reads the payload only from a transaction the owner sent straight to the Vault.
  if (
    !receipt.to ||
    !isAddressEqual(receipt.to, vault) ||
    !isAddressEqual(receipt.from, account.address)
  )
    throw new Error(
      `${txHash} was not sent by ${account.address} directly to the Vault: the approval counts, but ` +
        "tougue. will not show the request. Use Path B",
    );
  const approved = parseEventLogs({ abi: SAFE_ABI, eventName: "ApproveHash", logs: receipt.logs });
  if (
    !approved.some(
      (l) =>
        isAddressEqual(l.address, vault) &&
        l.args.approvedHash.toLowerCase() === safeTxHash.toLowerCase() &&
        isAddressEqual(l.args.owner, account.address),
    )
  )
    throw new Error("ApproveHash event missing from the receipt");

  return {
    safeTxHash,
    nonce,
    tx,
    txHash,
    blockNumber: receipt.blockNumber,
    threshold: info.threshold,
    simulation,
    url: chainId in TOUGUE_CHAIN_PREFIX ? requestUrl(vault, safeTxHash, chainId) : null,
  };
}

export function requestUrl(
  vault: Address,
  safeTxHash: Hash,
  chainId: number = KUB_CHAIN_ID,
): string {
  const prefix = TOUGUE_CHAIN_PREFIX[chainId];
  if (!prefix) throw new Error(`tougue. has no link format for chain ${chainId}`);
  return `${TOUGUE_APP_URL}/vault/tx/?safe=${prefix}:${getAddress(vault)}&hash=${safeTxHash.toLowerCase()}`;
}
readVault() / decodeRequestCalldata() / nextFreeNonce()
export interface VaultInfo {
  version: SafeVersion;
  nonce: bigint;
  threshold: bigint;
  owners: readonly Address[];
}

export async function readVault(client: PublicClient, vault: Address): Promise<VaultInfo> {
  const read = { address: vault, abi: SAFE_ABI } as const;
  const [version, nonce, threshold, owners] = await Promise.all([
    client.readContract({ ...read, functionName: "VERSION" }),
    client.readContract({ ...read, functionName: "nonce" }),
    client.readContract({ ...read, functionName: "getThreshold" }),
    client.readContract({ ...read, functionName: "getOwners" }),
  ]);
  if (version !== "1.3.0" && version !== "1.4.1" && version !== "1.5.0")
    throw new Error(`Unsupported Safe version ${version}`);
  return { version, nonce, threshold, owners };
}

/**
 * Next nonce after every request already queued on-chain: the highest pending request nonce + 1, or the Vault's
 * nonce when nothing is pending (the same rule as tougue.). Scan from the Vault's creation block (KUB Scan:
 * contract creation), any block before its oldest pending request, or 0n (KUB RPCs filter the full history).
 * A Path B proposal is not on-chain until an owner proposes it, so it does not reserve its nonce.
 * Requests are ApproveHash events whose transaction carries a payload that hashes to the approved hash. Each
 * transaction is read by block and position: public KUB RPCs cannot look up a hash older than about 81 days.
 */
export async function nextFreeNonce(
  client: PublicClient,
  vault: Address,
  opts: { fromBlock: bigint },
): Promise<bigint> {
  vault = getAddress(vault);
  const [{ nonce }, chainId, logs] = await Promise.all([
    readVault(client, vault),
    client.getChainId(),
    client.getLogs({
      address: vault,
      event: APPROVE_HASH_EVENT,
      fromBlock: opts.fromBlock,
      toBlock: "latest",
      strict: true,
    }),
  ]);
  let next = nonce;
  const seen = new Set<string>();
  for (const log of logs) {
    const key = `${log.blockNumber}:${log.transactionIndex}`;
    if (seen.has(key)) continue;
    seen.add(key);
    const t = await client
      .getTransaction({ blockNumber: log.blockNumber, index: log.transactionIndex })
      .catch(() => null);
    if (!t || t.hash.toLowerCase() !== log.transactionHash.toLowerCase()) continue;
    if (!t.to || !isAddressEqual(t.to, vault) || !isAddressEqual(t.from, log.args.owner)) continue;
    const req = decodeRequestCalldata(t.input);
    if (!req || req.safeTxHash.toLowerCase() !== log.args.approvedHash.toLowerCase()) continue;
    // Only a payload that hashes to what the Vault recorded is a request (tougue. ignores the rest).
    if (hashSafeTx(chainId, vault, req.tx).toLowerCase() !== log.args.approvedHash.toLowerCase())
      continue;
    if (req.tx.nonce >= next) next = req.tx.nonce + 1n;
  }
  return next;
}
  1. 1Pick the next free nonce, after every request already on-chain (public reads, no wallet).
  2. 2Build the SafeTx, simulate it as the Vault, and build the ImportedProposal JSON.
  3. 3Give an owner the link (or the JSON file).
  4. 4The owner opens it in tougue.: Vault verified, hash recomputed, call decoded and simulated. Propose on-chain publishes it (Path A, from the owner's wallet).
path-b.ts
import { createPublicClient, encodeFunctionData, http, parseAbi } from "viem";
import type { Address } from "viem";
import { buildOwnerRequest, kub } from "./tougue-request";

// Replace these with your own values.
declare const VAULT: Address; // the Vault
declare const VAULT_CREATED_AT_BLOCK: bigint; // its creation block (KUB Scan), or 0n
declare const TARGET: Address; // any contract the Vault controls

const publicClient = createPublicClient({ chain: kub, transport: http() }); // no wallet needed

const data = encodeFunctionData({
  abi: parseAbi(["function setFee(uint256 feeBps)"]), // your contract's function
  functionName: "setFee",
  args: [30n],
});

const { json, link, safeTxHash } = await buildOwnerRequest({
  publicClient,
  vault: VAULT,
  call: { to: TARGET, data },
  note: "Set fee to 0.30%",
  origin: "your-dapp", // shown to the owner as unverified text
  fromBlock: VAULT_CREATED_AT_BLOCK, // queues behind requests already on-chain (not reserved)
});

// Give `link` (or the `json` file) to a Vault owner. They open it in tougue.,
// review the decoded call and press "Propose on-chain".
console.log(link, safeTxHash, json);
proposal link
https://tougue.xyz/open/#proposal=<base64url(deflate-raw(JSON)), no padding>
proposal.json (placeholder addresses)
{
  "type": "toung.proposal",
  "version": 1,
  "chainId": 96,
  "safe": "0x1111111111111111111111111111111111111111",
  "tx": {
    "to": "0x2222222222222222222222222222222222222222",
    "value": "0",
    "data": "0x69fe0e2d000000000000000000000000000000000000000000000000000000000000001e",
    "operation": 0,
    "safeTxGas": "0",
    "baseGas": "0",
    "gasPrice": "0",
    "gasToken": "0x0000000000000000000000000000000000000000",
    "refundReceiver": "0x0000000000000000000000000000000000000000",
    "nonce": "7"
  },
  "safeTxHash": "0xeba8a9cdf4bb670ce478de4ca5c99db1aa95084d0e63af23fcc6a756e7401fb6",
  "note": "Set fee to 0.30%",
  "origin": "your-dapp"
}
  • Strict schema; unknown keys are rejected. Numbers are decimal strings except version and operation.
  • type stays "toung.proposal" (wire value). Max 300,000 bytes.
  • safeTxHash is optional; if present it must match. origin is shown as unverified text, cut to 64 characters.
  • The nonce is not reserved until an owner proposes it on-chain. Build one Path B request at a time per Vault, or pass explicit increasing nonces.
  • The #fragment never reaches a server. Very large calldata: send the JSON file.
Inside buildOwnerRequest(): JSON and link
ImportedProposal + link
/** Path B: JSON an owner imports in tougue. (Open → Import proposal). Numbers are decimal strings. */
export interface ImportedProposalV1 {
  type: "toung.proposal"; // wire value, kept from before the rename
  version: 1;
  chainId: number;
  safe: string;
  tx: {
    to: string;
    value: string;
    data: string;
    operation: 0 | 1;
    safeTxGas: string;
    baseGas: string;
    gasPrice: string;
    gasToken: string;
    refundReceiver: string;
    nonce: string;
  };
  note?: string;
  /** Optional cross-check: tougue. recomputes it and refuses a mismatch. */
  safeTxHash?: string;
  /** Shown to the owner as unverified text, cut to 64 characters. */
  origin?: string;
}

export function buildImportedProposal(p: {
  vault: Address;
  tx: SafeTx;
  note?: string;
  origin?: string;
  chainId?: number;
}): ImportedProposalV1 {
  const chainId = p.chainId ?? KUB_CHAIN_ID;
  const vault = getAddress(p.vault);
  const { tx } = p;
  // The same limits tougue.'s importer applies: fail here, not when the owner opens the link.
  checkNote(p.note ?? "");
  checkDataSize(tx.data);
  if (p.origin && p.origin.length > MAX_ORIGIN_CHARS)
    throw new Error(`origin exceeds ${MAX_ORIGIN_CHARS} characters`);
  const out: ImportedProposalV1 = {
    type: "toung.proposal",
    version: 1,
    chainId,
    safe: vault,
    tx: {
      to: getAddress(tx.to),
      value: tx.value.toString(),
      data: tx.data.toLowerCase(),
      operation: tx.operation,
      safeTxGas: tx.safeTxGas.toString(),
      baseGas: tx.baseGas.toString(),
      gasPrice: tx.gasPrice.toString(),
      gasToken: getAddress(tx.gasToken),
      refundReceiver: getAddress(tx.refundReceiver),
      nonce: tx.nonce.toString(),
    },
    safeTxHash: hashSafeTx(chainId, vault, tx),
  };
  if (p.note) out.note = p.note;
  if (p.origin) out.origin = p.origin;
  const bytes = new TextEncoder().encode(JSON.stringify(out, null, 2)).length;
  if (bytes > MAX_PROPOSAL_JSON_BYTES)
    throw new Error(
      `proposal JSON is ${bytes} bytes; tougue. accepts at most ${MAX_PROPOSAL_JSON_BYTES}`,
    );
  return out;
}

/** https://tougue.xyz/open/#proposal=<base64url(deflate-raw(JSON))>. The fragment never hits a server. */
export async function proposalLink(proposal: ImportedProposalV1): Promise<string> {
  const json = new TextEncoder().encode(JSON.stringify(proposal));
  const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw"));
  const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  const b64url = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${TOUGUE_APP_URL}/open/#proposal=${b64url}`;
}

/**
 * Path B in one call: reads the Vault (no wallet needed), picks the nonce, simulates the call and returns the
 * JSON and the link. The nonce is NOT reserved until an owner proposes it on-chain: build one Path B request at a
 * time per Vault, or pass explicit increasing `nonce`s. `chainId` defaults to KUB (96), like sendRequest().
 */
export async function buildOwnerRequest(
  p: {
    publicClient: PublicClient;
    vault: Address;
    call: Call;
    note?: string;
    origin?: string;
    chainId?: number;
    /** Default true: refuse a call that reverts in simulation. */
    simulate?: boolean;
  } & NonceChoice,
): Promise<{
  proposal: ImportedProposalV1;
  json: string;
  link: string;
  safeTxHash: Hash;
  nonce: bigint;
  tx: SafeTx;
  simulation: Simulation | null;
}> {
  const chainId = p.chainId ?? KUB_CHAIN_ID;
  if ((await p.publicClient.getChainId()) !== chainId) throw new Error("wrong network");
  const vault = getAddress(p.vault);
  const info = await readVault(p.publicClient, vault);
  const nonce = await chooseNonce(p.publicClient, vault, info, p);
  const tx = buildSafeTx(p.call, nonce);
  if (tx.operation === 1 && !isAddressEqual(tx.to, MULTI_SEND_CALL_ONLY[info.version]))
    throw new Error(`Batches must use MultiSendCallOnly ${info.version}`);
  const simulation =
    p.simulate === false ? null : await checkSimulation(p.publicClient, vault, tx, info.version);
  const proposal = buildImportedProposal({
    vault,
    tx,
    chainId,
    ...(p.note ? { note: p.note } : {}),
    ...(p.origin ? { origin: p.origin } : {}),
  });
  return {
    proposal,
    json: JSON.stringify(proposal, null, 2),
    link: await proposalLink(proposal),
    safeTxHash: hashSafeTx(chainId, vault, tx),
    nonce,
    tx,
    simulation,
  };
}

"Request in tougue." button

RequestInTougueButton.tsx
export function RequestInTougueButton({ link }: { link: string }) {
  return (
    <a
      href={link}
      target="_blank"
      rel="noopener noreferrer"
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 8,
        padding: "10px 16px",
        borderRadius: 12,
        background: "#0b0b0c",
        color: "#fafafa",
        font: "600 14px/1 system-ui, sans-serif",
        textDecoration: "none",
      }}
    >
      Request in tougue<span style={{ color: "#22c55e" }}>.</span>
    </a>
  );
}
plain HTML
<a href="https://tougue.xyz/open/#proposal=…" target="_blank" rel="noopener noreferrer">
  Request in tougue.
</a>

Several calls, one request, executed atomically: a DELEGATECALL to Safe's MultiSendCallOnly for the Vault's version. Every inner call is a CALL. Up to 50 calls.

Safe 1.5.0
0xA83c336B20401Af773B6219BA5027174338D1836
Safe 1.4.1
0x9641d764fc13c8B624c04430C7356C1C7C8102e2
Safe 1.3.0
0x40A2aCCbd92BCA938b02010E17A5b8929b49130D
batch.ts
import { createPublicClient, encodeFunctionData, erc20Abi, http, parseAbi } from "viem";
import type { Address } from "viem";
import { buildBatchCall, buildOwnerRequest, kub, readVault } from "./tougue-request";

// Replace these with your own values.
declare const VAULT: Address; // the Vault
declare const VAULT_CREATED_AT_BLOCK: bigint; // its creation block (KUB Scan), or 0n
declare const POOL: Address; // any contract that pulls tokens after an approve

const publicClient = createPublicClient({ chain: kub, transport: http() });
const { version } = await readVault(publicClient, VAULT);

const pool = parseAbi(["function deposit(uint256 amount)"]);
const amount = 10n ** 18n;
const call = buildBatchCall(version, [
  {
    to: "0x67eBD850304c70d983B2d1b93ea79c7CD6c3F6b5", // KKUB
    data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [POOL, amount] }),
  },
  { to: POOL, data: encodeFunctionData({ abi: pool, functionName: "deposit", args: [amount] }) },
]);

// `call` is a DELEGATECALL to MultiSendCallOnly. It works with sendRequest() (Path A) too.
const { link } = await buildOwnerRequest({
  publicClient,
  vault: VAULT,
  call,
  note: "Approve + deposit 1 KKUB",
  fromBlock: VAULT_CREATED_AT_BLOCK,
});
console.log(link);
buildBatchCall()
/** One request, many calls: DELEGATECALL to the Vault version's MultiSendCallOnly; inner calls are CALLs. */
export function buildBatchCall(
  version: SafeVersion,
  calls: readonly { to: Address; value?: bigint; data?: Hex }[],
): Call {
  if (calls.length < 1 || calls.length > 50) throw new Error("a batch needs 1 to 50 calls");
  const packed = concat(
    calls.map((c) => {
      const data = c.data ?? "0x";
      return encodePacked(
        ["uint8", "address", "uint256", "uint256", "bytes"],
        [0, getAddress(c.to), c.value ?? 0n, BigInt(size(data)), data],
      );
    }),
  );
  return {
    to: MULTI_SEND_CALL_ONLY[version],
    value: 0n,
    data: encodeFunctionData({ abi: MULTI_SEND_ABI, functionName: "multiSend", args: [packed] }),
    operation: 1,
  };
}
  • Pending: request nonce ≥ Vault nonce(). Approvals = current owners with approvedHashes(owner, safeTxHash) ≠ 0.
  • Executed: the Vault emitted ExecutionSuccess(bytes32 txHash, uint256 payment) with your safeTxHash (indexed from 1.4.1, in data in 1.3.0).
  • Replaced: the nonce was used by another transaction, such as a rejection.
status.ts
import { createPublicClient, http } from "viem";
import type { Address, Hash } from "viem";
import { getRequestStatus, kub } from "./tougue-request";

// Replace these with your own values (sendRequest() returns the last three).
declare const VAULT: Address; // the Vault
declare const safeTxHash: Hash; // the request's hash
declare const nonce: bigint; // the request's nonce
declare const sentAtBlock: bigint; // the block of your request transaction

const publicClient = createPublicClient({ chain: kub, transport: http() });
const status = await getRequestStatus(publicClient, VAULT, {
  safeTxHash,
  nonce,
  fromBlock: sentAtBlock,
});

if (status.state === "pending")
  console.log(`${status.approvals.length}/${status.threshold} approvals, ready: ${status.ready}`);
if (status.state === "executed") {
  // ExecutionSuccess proves the Safe call did not revert, NOT that your contract did what you wanted.
  // Some contracts return an error code instead of reverting: re-read the state you changed.
}
Receipt success ≠ business success. Some contracts (Compound-style admin functions, for example) return an error code instead of reverting, so the Safe reports success. After execution, re-read the state you changed.
Inside getRequestStatus()
getRequestStatus()
const EXECUTION_SUCCESS = toEventSelector("ExecutionSuccess(bytes32,uint256)");
const EXECUTION_FAILURE = toEventSelector("ExecutionFailure(bytes32,uint256)");

export type RequestStatus =
  | { state: "pending"; approvals: Address[]; threshold: bigint; ready: boolean }
  | { state: "executed"; txHash: Hash; blockNumber: bigint }
  | { state: "failed"; txHash: Hash; blockNumber: bigint }
  /** The nonce was used by another transaction (for example a rejection). */
  | { state: "replaced" };

export async function getRequestStatus(
  client: PublicClient,
  vault: Address,
  req: { safeTxHash: Hash; nonce: bigint; fromBlock: bigint },
): Promise<RequestStatus> {
  const info = await readVault(client, vault);
  if (req.nonce >= info.nonce) {
    // Approvals are on-chain flags; only CURRENT owners count.
    const flags = await Promise.all(
      info.owners.map((o) =>
        client.readContract({
          address: vault,
          abi: SAFE_ABI,
          functionName: "approvedHashes",
          args: [o, req.safeTxHash],
        }),
      ),
    );
    const approvals = info.owners.filter((_, i) => flags[i] !== 0n);
    return {
      state: "pending",
      approvals,
      threshold: info.threshold,
      ready: req.nonce === info.nonce && BigInt(approvals.length) >= info.threshold,
    };
  }
  // ExecutionSuccess/Failure(bytes32 txHash, uint256 payment): txHash is indexed from 1.4.1, in data in 1.3.0.
  const logs = await client.request({
    method: "eth_getLogs",
    params: [
      {
        address: vault,
        fromBlock: numberToHex(req.fromBlock),
        toBlock: "latest",
        topics: [[EXECUTION_SUCCESS, EXECUTION_FAILURE]],
      },
    ],
  });
  for (const log of logs) {
    const hash = log.topics[1] ?? slice(log.data, 0, 32);
    if (hash.toLowerCase() !== req.safeTxHash.toLowerCase() || !log.transactionHash) continue;
    const at = { txHash: log.transactionHash, blockNumber: hexToBigInt(log.blockNumber ?? "0x0") };
    return log.topics[0] === EXECUTION_SUCCESS
      ? { state: "executed", ...at }
      : { state: "failed", ...at };
  }
  return { state: "replaced" };
}

At threshold, anyone executes: encodeExecute(tx, status.approvals) sent to the Vault as a legacy transaction. tx is sendRequest().tx (Path A) or buildSafeTx(call, nonce) (Path B). Most teams let an owner press Execute in tougue.

execute.ts
import type { Account, Address, Chain, PublicClient, Transport, WalletClient } from "viem";
import { encodeExecute, getRequestStatus, type SentRequest } from "./tougue-request";

// Replace these with your own values.
declare const publicClient: PublicClient;
declare const walletClient: WalletClient<Transport, Chain, Account>; // any account, on KUB
declare const VAULT: Address; // the Vault
declare const request: SentRequest; // from sendRequest(). Path B: tx = buildSafeTx(call, nonce)

const { safeTxHash, nonce, tx, blockNumber } = request;
const status = await getRequestStatus(publicClient, VAULT, {
  safeTxHash,
  nonce,
  fromBlock: blockNumber,
});
// Ready = threshold met and this is the Vault's next nonce. Anyone may execute; it needs no ownership.
if (status.state === "pending" && status.ready) {
  await walletClient.sendTransaction({
    to: VAULT,
    data: encodeExecute(tx, status.approvals),
    type: "legacy", // KUB: no EIP-1559
    gasPrice: await publicClient.getGasPrice(),
  });
}
Inside encodeExecute()
encodeExecute()
/**
 * Anyone may execute once `threshold` current owners approved (getRequestStatus() → ready). Pass the request's
 * SafeTx (sendRequest().tx, or buildSafeTx(call, nonce)) and status.approvals. Signatures are v=1 "approved hash"
 * entries, sorted by owner address: r = owner, s = 0, v = 1. Send it to the Vault as a legacy transaction.
 */
export function encodeExecute(tx: SafeTx, approvers: readonly Address[]): Hex {
  const owners = [...new Set(approvers.map((a) => getAddress(a)))].sort((a, b) =>
    hexToBigInt(a) < hexToBigInt(b) ? -1 : 1,
  );
  const signatures = concat(
    owners.map((o) => encodePacked(["uint256", "uint256", "uint8"], [hexToBigInt(o), 0n, 1])),
  );
  return encodeFunctionData({
    abi: SAFE_ABI,
    functionName: "execTransaction",
    args: [
      tx.to,
      tx.value,
      tx.data,
      tx.operation,
      tx.safeTxGas,
      tx.baseGas,
      tx.gasPrice,
      tx.gasToken,
      tx.refundReceiver,
      signatures,
    ],
  });
}
  • Real test, gas only: create a 1-of-1 Vault in tougue. with your own wallet and send it requests. One request ≈ 0.0012 KUB; it shows up in tougue. at once.
  • Mainnet fork (Anvil, Hardhat): chainId stays 96 and the Safe contracts are there. Change only the RPC: http("http://127.0.0.1:8545").
  • Other chain or devnet with the official Safe contracts: pass chainId to sendRequest() / buildOwnerRequest() and your own viem chain. url is then null.
  • tougue.xyz opens KUB mainnet Vaults only. KUB testnet is off: no Safe deployment is registered there.
  • fromBlock: the Vault's creation block (KUB Scan, contract creation). Unknown? 0n works on KUB.
What to send
operation = 0 (CALL), or a DELEGATECALL to this version's MultiSendCallOnly

DELEGATECALL runs foreign code as the Vault. Batches go through MultiSendCallOnly, whose inner calls are always CALLs.

safeTxGas, baseGas, gasPrice = 0; gasToken, refundReceiver = 0x0

Execution stays atomic (a failing call reverts) and the Vault never pays a refund.

data ≤ 128 KiB (131,072 bytes)

Larger requests are ignored on-chain and rejected on import. The helper throws before sending or building a link.

Note ≤ 280 UTF-8 bytes

Public forever and not covered by the safeTxHash. No secrets, no personal data. The helper throws on a longer note (Path A and B).

Next free nonce

Reusing a pending nonce creates a conflict: only one can ever execute. Pass fromBlock and the helper picks it. A Path B nonce is not reserved until proposed on-chain.

Path A: an EOA owner, sending straight to the Vault

tougue. reads a request only from a transaction sent by the owner to the Vault. A non-owner reverts with GS030; a smart-account owner's inner call is not read. Use Path B for both.

Path B: proposal JSON ≤ 300,000 bytes

The import limit for the JSON file or link.

Legacy transactions only

KUB has no EIP-1559 and rejects type 1/2 transactions. Always send an explicit gasPrice.

Approvals are irrevocable

To cancel, owners approve a rejection (empty tx to the Vault) at the same nonce.

Never ask for keys or seed phrases

Every transaction is signed in the user's own wallet.

What tougue. enforces

Check
gasPrice, gasToken or refundReceiver set

Blocked: the Vault would pay the executor.

safeTxGas ≠ 0

Flagged on an on-chain request (a failing call would not revert and still uses the nonce). On import, safeTxGas or baseGas ≠ 0 is blocked.

DELEGATECALL

Accepted only to this Vault version's MultiSend or MultiSendCallOnly, and only if every inner call is a CALL. Anything else is blocked.

Payload

Must re-encode byte for byte and hash to the approved hash for this Vault and chain, with data ≤ 128 KiB and a note ≤ 280 bytes. Anything else is ignored.

Nonce

On import, a used nonce is blocked and a nonce past the next free one shows a warning.

Review

Every request is decoded, simulated and risk-labelled before an owner approves. Approve nothing you cannot read.

Everything above in one file. tougue.'s test suite executes this exact source against its own decoder and import parser.

794 lines · depends on viem 2.x only
Show the full file
tougue-request.ts
// tougue-request.ts: send a request to a tougue. Vault (an official Safe on KUB Chain) using viem only.
// Docs: https://tougue.xyz/docs/
// Copy this file into your dApp. It has one dependency (viem 2.x) and works in browsers and Node 20.12+
// (Path B links use CompressionStream("deflate-raw")).
//
// A request is an on-chain proposal: a Vault OWNER calls approveHash(safeTxHash) on the Vault and appends the full
// SafeTx to the calldata. Solidity ignores the extra bytes; tougue. reads them back, re-hashes them and shows the
// request to every owner. On-chain format v1 (never changes):
//   approveHash(bytes32 safeTxHash) ‖ "TOUNG" (0x544f554e47) ‖ 0x01 ‖ abi.encode(10 SafeTx fields, string note)

import {
  concat,
  decodeAbiParameters,
  defineChain,
  encodeAbiParameters,
  encodeFunctionData,
  encodePacked,
  getAddress,
  hashTypedData,
  hexToBigInt,
  isAddressEqual,
  numberToHex,
  parseAbi,
  parseAbiItem,
  parseEventLogs,
  size,
  slice,
  toEventSelector,
  zeroAddress,
  type Address,
  type Hash,
  type Hex,
  type PublicClient,
  type WalletClient,
} from "viem";

export const KUB_CHAIN_ID = 96;
export const TOUGUE_APP_URL = "https://tougue.xyz";
/** Chain prefixes in tougue. links. tougue.xyz opens `kub` Vaults; `tkub` and `local` exist only in dev builds. */
export const TOUGUE_CHAIN_PREFIX: Readonly<Record<number, string>> = {
  96: "kub",
  25925: "tkub",
  31337: "local",
};

export const kub = defineChain({
  id: 96,
  name: "KUB Mainnet",
  nativeCurrency: { name: "KUB Coin", symbol: "KUB", decimals: 18 },
  rpcUrls: { default: { http: ["https://rpc.bitkubchain.io"] } },
  blockExplorers: { default: { name: "KUB Scan", url: "https://www.kubscan.com" } },
});

export type SafeVersion = "1.3.0" | "1.4.1" | "1.5.0";

/**
 * Safe's official MultiSendCallOnly per Vault version: the DELEGATECALL target for batches. (tougue. also accepts
 * this version's MultiSend when every inner call is a CALL; this helper only builds MultiSendCallOnly batches.)
 */
export const MULTI_SEND_CALL_ONLY: Readonly<Record<SafeVersion, Address>> = {
  "1.3.0": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D",
  "1.4.1": "0x9641d764fc13c8B624c04430C7356C1C7C8102e2",
  "1.5.0": "0xA83c336B20401Af773B6219BA5027174338D1836",
};

/** Safe's official SimulateTxAccessor per Vault version: runs a SafeTx in the Vault's context, then reverts. */
export const SIMULATE_TX_ACCESSOR: Readonly<Record<SafeVersion, Address>> = {
  "1.3.0": "0x59AD6735bCd8152B84860Cb256dD9e96b85F69Da",
  "1.4.1": "0x3d4BA2E0884aa488718476ca2FB8Efc291A46199",
  "1.5.0": "0x07EfA797c55B5DdE3698d876b277aBb6B893654C",
};

/** "TOUNG" ‖ payload version 0x01. Wire format, kept from before the tougue. rename. */
export const REQUEST_PREFIX_V1: Hex = "0x544f554e4701";
export const MAX_NOTE_BYTES = 280;
/** tougue. ignores (on-chain) or rejects (import) a request whose `data` is larger than 128 KiB. */
export const MAX_DATA_BYTES = 131_072;
/** Path B: tougue. rejects a proposal JSON (file or decoded link) larger than this. */
export const MAX_PROPOSAL_JSON_BYTES = 300_000;
/** Path B: `origin` is shown cut to 64 characters; the schema rejects more than 1024. */
export const MAX_ORIGIN_CHARS = 1024;

export const SAFE_ABI = parseAbi([
  "function VERSION() view returns (string)",
  "function nonce() view returns (uint256)",
  "function getThreshold() view returns (uint256)",
  "function getOwners() view returns (address[])",
  "function approvedHashes(address owner, bytes32 hash) view returns (uint256)",
  "function getTransactionHash(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, uint256 _nonce) view returns (bytes32)",
  "function approveHash(bytes32 hashToApprove)",
  "function simulateAndRevert(address targetContract, bytes calldataPayload)",
  "function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)",
  "event ApproveHash(bytes32 indexed approvedHash, address indexed owner)",
]);
const APPROVE_HASH_EVENT = parseAbiItem(
  "event ApproveHash(bytes32 indexed approvedHash, address indexed owner)",
);
const MULTI_SEND_ABI = parseAbi(["function multiSend(bytes transactions) payable"]);
const SIMULATE_ABI = parseAbi([
  "function simulate(address to, uint256 value, bytes data, uint8 operation) returns (uint256 estimate, bool success, bytes returnData)",
]);

/** The 10 fields Safe signs. tougue. requires safeTxGas = baseGas = gasPrice = 0 and zero refund addresses. */
export interface SafeTx {
  to: Address;
  value: bigint;
  data: Hex;
  operation: 0 | 1; // 0 = CALL, 1 = DELEGATECALL (batches: MultiSendCallOnly only)
  safeTxGas: bigint;
  baseGas: bigint;
  gasPrice: bigint;
  gasToken: Address;
  refundReceiver: Address;
  nonce: bigint;
}

export interface Call {
  to: Address;
  value?: bigint;
  data?: Hex;
  operation?: 0 | 1;
}

function checkDataSize(data: Hex): void {
  if (size(data) > MAX_DATA_BYTES)
    throw new Error(`data is ${size(data)} bytes; tougue. accepts at most ${MAX_DATA_BYTES}`);
}

function checkNote(note: string): void {
  if (new TextEncoder().encode(note).length > MAX_NOTE_BYTES)
    throw new Error(`note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`);
}

export function buildSafeTx(call: Call, nonce: bigint): SafeTx {
  const operation = call.operation ?? 0;
  const to = getAddress(call.to);
  if (operation === 1 && !Object.values(MULTI_SEND_CALL_ONLY).some((a) => isAddressEqual(a, to)))
    throw new Error("DELEGATECALL is allowed only to Safe's MultiSendCallOnly");
  const data = call.data ?? "0x";
  checkDataSize(data);
  return {
    to,
    value: call.value ?? 0n,
    data,
    operation,
    safeTxGas: 0n,
    baseGas: 0n,
    gasPrice: 0n,
    gasToken: zeroAddress,
    refundReceiver: zeroAddress,
    nonce,
  };
}

const SAFE_TX_TYPES = {
  SafeTx: [
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "data", type: "bytes" },
    { name: "operation", type: "uint8" },
    { name: "safeTxGas", type: "uint256" },
    { name: "baseGas", type: "uint256" },
    { name: "gasPrice", type: "uint256" },
    { name: "gasToken", type: "address" },
    { name: "refundReceiver", type: "address" },
    { name: "nonce", type: "uint256" },
  ],
} as const;

/** Offline EIP-712 hash. Domain = { chainId, verifyingContract: vault } (Safe 1.3.0+). */
export function hashSafeTx(chainId: number, vault: Address, tx: SafeTx): Hash {
  return hashTypedData({
    domain: { chainId, verifyingContract: getAddress(vault) },
    types: SAFE_TX_TYPES,
    primaryType: "SafeTx",
    message: tx,
  });
}

/** The Vault's own hash (eth_call). Must equal hashSafeTx(); sendRequest() checks both. */
export function readSafeTxHash(client: PublicClient, vault: Address, tx: SafeTx): Promise<Hash> {
  return client.readContract({
    address: vault,
    abi: SAFE_ABI,
    functionName: "getTransactionHash",
    args: [
      tx.to,
      tx.value,
      tx.data,
      tx.operation,
      tx.safeTxGas,
      tx.baseGas,
      tx.gasPrice,
      tx.gasToken,
      tx.refundReceiver,
      tx.nonce,
    ],
  });
}

const PAYLOAD_V1 = [
  { type: "address", name: "to" },
  { type: "uint256", name: "value" },
  { type: "bytes", name: "data" },
  { type: "uint8", name: "operation" },
  { type: "uint256", name: "safeTxGas" },
  { type: "uint256", name: "baseGas" },
  { type: "uint256", name: "gasPrice" },
  { type: "address", name: "gasToken" },
  { type: "address", name: "refundReceiver" },
  { type: "uint256", name: "nonce" },
  { type: "string", name: "note" },
] as const;

/**
 * approveHash(safeTxHash) ‖ 0x544f554e47 ‖ 0x01 ‖ abi.encode(...11 params, NOT a tuple).
 * The note is public forever and is not covered by the safeTxHash. Max 280 UTF-8 bytes; data max 128 KiB.
 */
export function encodeRequestCalldata(safeTxHash: Hash, tx: SafeTx, note = ""): Hex {
  checkNote(note);
  checkDataSize(tx.data);
  const approve = encodeFunctionData({
    abi: SAFE_ABI,
    functionName: "approveHash",
    args: [safeTxHash],
  });
  const payload = encodeAbiParameters(PAYLOAD_V1, [
    tx.to,
    tx.value,
    tx.data,
    tx.operation,
    tx.safeTxGas,
    tx.baseGas,
    tx.gasPrice,
    tx.gasToken,
    tx.refundReceiver,
    tx.nonce,
    note,
  ]);
  return concat([approve, REQUEST_PREFIX_V1, payload]);
}

const REQUEST_HEAD_BYTES = 4 + 32 + 6; // approveHash selector + safeTxHash + prefix

/** The reverse of encodeRequestCalldata(). Returns null for anything that is not a canonical v1 request. */
export function decodeRequestCalldata(
  input: Hex,
): { safeTxHash: Hash; tx: SafeTx; note: string } | null {
  try {
    if (size(input) < REQUEST_HEAD_BYTES + 11 * 32) return null;
    if (slice(input, 0, 4).toLowerCase() !== "0xd4d9bdcd") return null; // approveHash(bytes32)
    if (slice(input, 36, REQUEST_HEAD_BYTES).toLowerCase() !== REQUEST_PREFIX_V1) return null;
    const body = slice(input, REQUEST_HEAD_BYTES);
    const v = decodeAbiParameters(PAYLOAD_V1, body);
    // Like tougue.: the payload must re-encode to the same bytes.
    if (encodeAbiParameters(PAYLOAD_V1, v).toLowerCase() !== body.toLowerCase()) return null;
    const [to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver] = v;
    if (operation !== 0 && operation !== 1) return null;
    const tx: SafeTx = {
      to: getAddress(to),
      value,
      data,
      operation,
      safeTxGas,
      baseGas,
      gasPrice,
      gasToken: getAddress(gasToken),
      refundReceiver: getAddress(refundReceiver),
      nonce: v[9],
    };
    return { safeTxHash: slice(input, 4, 36), tx, note: v[10] };
  } catch {
    return null;
  }
}

export interface VaultInfo {
  version: SafeVersion;
  nonce: bigint;
  threshold: bigint;
  owners: readonly Address[];
}

export async function readVault(client: PublicClient, vault: Address): Promise<VaultInfo> {
  const read = { address: vault, abi: SAFE_ABI } as const;
  const [version, nonce, threshold, owners] = await Promise.all([
    client.readContract({ ...read, functionName: "VERSION" }),
    client.readContract({ ...read, functionName: "nonce" }),
    client.readContract({ ...read, functionName: "getThreshold" }),
    client.readContract({ ...read, functionName: "getOwners" }),
  ]);
  if (version !== "1.3.0" && version !== "1.4.1" && version !== "1.5.0")
    throw new Error(`Unsupported Safe version ${version}`);
  return { version, nonce, threshold, owners };
}

/**
 * Next nonce after every request already queued on-chain: the highest pending request nonce + 1, or the Vault's
 * nonce when nothing is pending (the same rule as tougue.). Scan from the Vault's creation block (KUB Scan:
 * contract creation), any block before its oldest pending request, or 0n (KUB RPCs filter the full history).
 * A Path B proposal is not on-chain until an owner proposes it, so it does not reserve its nonce.
 * Requests are ApproveHash events whose transaction carries a payload that hashes to the approved hash. Each
 * transaction is read by block and position: public KUB RPCs cannot look up a hash older than about 81 days.
 */
export async function nextFreeNonce(
  client: PublicClient,
  vault: Address,
  opts: { fromBlock: bigint },
): Promise<bigint> {
  vault = getAddress(vault);
  const [{ nonce }, chainId, logs] = await Promise.all([
    readVault(client, vault),
    client.getChainId(),
    client.getLogs({
      address: vault,
      event: APPROVE_HASH_EVENT,
      fromBlock: opts.fromBlock,
      toBlock: "latest",
      strict: true,
    }),
  ]);
  let next = nonce;
  const seen = new Set<string>();
  for (const log of logs) {
    const key = `${log.blockNumber}:${log.transactionIndex}`;
    if (seen.has(key)) continue;
    seen.add(key);
    const t = await client
      .getTransaction({ blockNumber: log.blockNumber, index: log.transactionIndex })
      .catch(() => null);
    if (!t || t.hash.toLowerCase() !== log.transactionHash.toLowerCase()) continue;
    if (!t.to || !isAddressEqual(t.to, vault) || !isAddressEqual(t.from, log.args.owner)) continue;
    const req = decodeRequestCalldata(t.input);
    if (!req || req.safeTxHash.toLowerCase() !== log.args.approvedHash.toLowerCase()) continue;
    // Only a payload that hashes to what the Vault recorded is a request (tougue. ignores the rest).
    if (hashSafeTx(chainId, vault, req.tx).toLowerCase() !== log.args.approvedHash.toLowerCase())
      continue;
    if (req.tx.nonce >= next) next = req.tx.nonce + 1n;
  }
  return next;
}

export type Simulation =
  | { status: "success"; gasUsed: bigint }
  | { status: "reverted"; returnData: Hex }
  /** The RPC could not run the simulation. tougue. simulates again when owners review. */
  | { status: "unavailable"; reason: string };

function revertData(err: unknown): Hex | undefined {
  for (let e: unknown = err, i = 0; e && typeof e === "object" && i < 8; i++) {
    const d = (e as { data?: unknown }).data;
    if (typeof d === "string" && d.startsWith("0x")) return d as Hex;
    const inner = d && typeof d === "object" ? (d as { data?: unknown }).data : undefined;
    if (typeof inner === "string" && inner.startsWith("0x")) return inner as Hex;
    e = (e as { cause?: unknown }).cause;
  }
  return undefined;
}

/**
 * Runs the SafeTx's call exactly as the Vault would (Safe's simulateAndRevert + SimulateTxAccessor, eth_call only;
 * nothing is sent). Use it before publishing: approvals are irrevocable, so a broken call wastes everyone's gas.
 */
export async function simulateRequest(
  client: PublicClient,
  vault: Address,
  tx: SafeTx,
  version: SafeVersion,
): Promise<Simulation> {
  const data = encodeFunctionData({
    abi: SAFE_ABI,
    functionName: "simulateAndRevert",
    args: [
      SIMULATE_TX_ACCESSOR[version],
      encodeFunctionData({
        abi: SIMULATE_ABI,
        functionName: "simulate",
        args: [tx.to, tx.value, tx.data, tx.operation],
      }),
    ],
  });
  let out: Hex | undefined;
  try {
    await client.call({ account: vault, to: vault, data });
    return { status: "unavailable", reason: "simulateAndRevert did not revert" };
  } catch (e) {
    out = revertData(e);
  }
  // Revert data = uint256 1 ‖ uint256 length ‖ abi.encode(uint256 gasUsed, bool success, bytes returnData).
  if (!out || size(out) < 64 || hexToBigInt(slice(out, 0, 32)) !== 1n)
    return { status: "unavailable", reason: "no simulation result from the RPC" };
  try {
    const [gasUsed, success, returnData] = decodeAbiParameters(
      [{ type: "uint256" }, { type: "bool" }, { type: "bytes" }],
      slice(out, 64),
    );
    return success ? { status: "success", gasUsed } : { status: "reverted", returnData };
  } catch {
    return { status: "unavailable", reason: "malformed simulation result" };
  }
}

/** Pick the request's nonce. `fromBlock` (recommended) queues it behind every request already on-chain. */
export type NonceChoice =
  { fromBlock: bigint; nonce?: undefined } | { nonce: bigint; fromBlock?: undefined };

async function chooseNonce(
  client: PublicClient,
  vault: Address,
  info: VaultInfo,
  c: NonceChoice,
): Promise<bigint> {
  const nonce = c.nonce ?? (await nextFreeNonce(client, vault, { fromBlock: c.fromBlock }));
  if (nonce < info.nonce) throw new Error(`nonce ${nonce} is already used`);
  return nonce;
}

async function checkSimulation(
  client: PublicClient,
  vault: Address,
  tx: SafeTx,
  version: SafeVersion,
): Promise<Simulation> {
  const sim = await simulateRequest(client, vault, tx, version);
  if (sim.status === "reverted")
    throw new Error(
      `the call reverts when the Vault runs it (${sim.returnData}). Fix it, or pass simulate: false ` +
        "if it only works after an earlier pending request executes",
    );
  return sim;
}

export interface SentRequest {
  safeTxHash: Hash;
  nonce: bigint;
  /** The SafeTx owners approve; pass it to encodeExecute() at threshold. */
  tx: SafeTx;
  txHash: Hash;
  blockNumber: bigint;
  threshold: bigint;
  /** The pre-publish simulation of the call ("unavailable" if the RPC could not run it). */
  simulation: Simulation | null;
  /** Where owners approve and anyone executes. null on a chain tougue. has no prefix for. */
  url: string | null;
}

/**
 * Path A: the wallet IS a Vault owner and an EOA (a normal wallet account) that sends the transaction to the Vault
 * itself. Publishes the request on-chain (about 50k gas). A smart-account owner must use Path B: tougue. only reads
 * requests sent by the owner directly to the Vault. `chainId` defaults to KUB (96); pass another to test on a fork
 * or devnet with the official Safe contracts.
 */
export async function sendRequest(
  p: {
    publicClient: PublicClient;
    walletClient: WalletClient;
    vault: Address;
    call: Call;
    note?: string;
    chainId?: number;
    /** Default true: refuse to publish a call that reverts in simulation. */
    simulate?: boolean;
  } & NonceChoice,
): Promise<SentRequest> {
  const chainId = p.chainId ?? KUB_CHAIN_ID;
  const account = p.walletClient.account;
  if (!account) throw new Error("walletClient has no account");
  if ((await p.publicClient.getChainId()) !== chainId) throw new Error("wrong network");
  const vault = getAddress(p.vault);

  const info = await readVault(p.publicClient, vault);
  if (!info.owners.some((o) => isAddressEqual(o, account.address)))
    throw new Error("Not a Vault owner: use Path B (buildOwnerRequest)");
  const code = await p.publicClient.getCode({ address: account.address });
  // A contract owner (another Safe, an AA wallet) cannot send the request itself. 0xef0100… = EIP-7702 EOA.
  if (code && code !== "0x" && !code.toLowerCase().startsWith("0xef0100"))
    throw new Error("The owner is a smart contract; Path A needs an EOA owner: use Path B");
  const nonce = await chooseNonce(p.publicClient, vault, info, p);

  const tx = buildSafeTx(p.call, nonce);
  if (tx.operation === 1 && !isAddressEqual(tx.to, MULTI_SEND_CALL_ONLY[info.version]))
    throw new Error(`Batches must use MultiSendCallOnly ${info.version}`);

  const safeTxHash = hashSafeTx(chainId, vault, tx);
  const onChain = await readSafeTxHash(p.publicClient, vault, tx);
  if (onChain.toLowerCase() !== safeTxHash.toLowerCase()) throw new Error("safeTxHash mismatch");
  const simulation =
    p.simulate === false ? null : await checkSimulation(p.publicClient, vault, tx, info.version);

  const data = encodeRequestCalldata(safeTxHash, tx, p.note);
  // Estimates the approveHash transaction as the owner (a non-owner reverts with GS030).
  const gas = await p.publicClient.estimateGas({ account: account.address, to: vault, data });
  const gasPrice = await p.publicClient.getGasPrice();
  // KUB has no EIP-1559: always a legacy (type 0) transaction with an explicit gasPrice.
  const txHash = await p.walletClient.sendTransaction({
    account,
    chain: p.walletClient.chain,
    to: vault,
    data,
    value: 0n,
    type: "legacy",
    gas: (gas * 12n) / 10n,
    gasPrice,
  });
  const receipt = await p.publicClient.waitForTransactionReceipt({ hash: txHash });
  if (receipt.status !== "success") throw new Error(`request transaction ${txHash} reverted`);
  // tougue. reads the payload only from a transaction the owner sent straight to the Vault.
  if (
    !receipt.to ||
    !isAddressEqual(receipt.to, vault) ||
    !isAddressEqual(receipt.from, account.address)
  )
    throw new Error(
      `${txHash} was not sent by ${account.address} directly to the Vault: the approval counts, but ` +
        "tougue. will not show the request. Use Path B",
    );
  const approved = parseEventLogs({ abi: SAFE_ABI, eventName: "ApproveHash", logs: receipt.logs });
  if (
    !approved.some(
      (l) =>
        isAddressEqual(l.address, vault) &&
        l.args.approvedHash.toLowerCase() === safeTxHash.toLowerCase() &&
        isAddressEqual(l.args.owner, account.address),
    )
  )
    throw new Error("ApproveHash event missing from the receipt");

  return {
    safeTxHash,
    nonce,
    tx,
    txHash,
    blockNumber: receipt.blockNumber,
    threshold: info.threshold,
    simulation,
    url: chainId in TOUGUE_CHAIN_PREFIX ? requestUrl(vault, safeTxHash, chainId) : null,
  };
}

export function requestUrl(
  vault: Address,
  safeTxHash: Hash,
  chainId: number = KUB_CHAIN_ID,
): string {
  const prefix = TOUGUE_CHAIN_PREFIX[chainId];
  if (!prefix) throw new Error(`tougue. has no link format for chain ${chainId}`);
  return `${TOUGUE_APP_URL}/vault/tx/?safe=${prefix}:${getAddress(vault)}&hash=${safeTxHash.toLowerCase()}`;
}

/** Path B: JSON an owner imports in tougue. (Open → Import proposal). Numbers are decimal strings. */
export interface ImportedProposalV1 {
  type: "toung.proposal"; // wire value, kept from before the rename
  version: 1;
  chainId: number;
  safe: string;
  tx: {
    to: string;
    value: string;
    data: string;
    operation: 0 | 1;
    safeTxGas: string;
    baseGas: string;
    gasPrice: string;
    gasToken: string;
    refundReceiver: string;
    nonce: string;
  };
  note?: string;
  /** Optional cross-check: tougue. recomputes it and refuses a mismatch. */
  safeTxHash?: string;
  /** Shown to the owner as unverified text, cut to 64 characters. */
  origin?: string;
}

export function buildImportedProposal(p: {
  vault: Address;
  tx: SafeTx;
  note?: string;
  origin?: string;
  chainId?: number;
}): ImportedProposalV1 {
  const chainId = p.chainId ?? KUB_CHAIN_ID;
  const vault = getAddress(p.vault);
  const { tx } = p;
  // The same limits tougue.'s importer applies: fail here, not when the owner opens the link.
  checkNote(p.note ?? "");
  checkDataSize(tx.data);
  if (p.origin && p.origin.length > MAX_ORIGIN_CHARS)
    throw new Error(`origin exceeds ${MAX_ORIGIN_CHARS} characters`);
  const out: ImportedProposalV1 = {
    type: "toung.proposal",
    version: 1,
    chainId,
    safe: vault,
    tx: {
      to: getAddress(tx.to),
      value: tx.value.toString(),
      data: tx.data.toLowerCase(),
      operation: tx.operation,
      safeTxGas: tx.safeTxGas.toString(),
      baseGas: tx.baseGas.toString(),
      gasPrice: tx.gasPrice.toString(),
      gasToken: getAddress(tx.gasToken),
      refundReceiver: getAddress(tx.refundReceiver),
      nonce: tx.nonce.toString(),
    },
    safeTxHash: hashSafeTx(chainId, vault, tx),
  };
  if (p.note) out.note = p.note;
  if (p.origin) out.origin = p.origin;
  const bytes = new TextEncoder().encode(JSON.stringify(out, null, 2)).length;
  if (bytes > MAX_PROPOSAL_JSON_BYTES)
    throw new Error(
      `proposal JSON is ${bytes} bytes; tougue. accepts at most ${MAX_PROPOSAL_JSON_BYTES}`,
    );
  return out;
}

/** https://tougue.xyz/open/#proposal=<base64url(deflate-raw(JSON))>. The fragment never hits a server. */
export async function proposalLink(proposal: ImportedProposalV1): Promise<string> {
  const json = new TextEncoder().encode(JSON.stringify(proposal));
  const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw"));
  const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  const b64url = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${TOUGUE_APP_URL}/open/#proposal=${b64url}`;
}

/**
 * Path B in one call: reads the Vault (no wallet needed), picks the nonce, simulates the call and returns the
 * JSON and the link. The nonce is NOT reserved until an owner proposes it on-chain: build one Path B request at a
 * time per Vault, or pass explicit increasing `nonce`s. `chainId` defaults to KUB (96), like sendRequest().
 */
export async function buildOwnerRequest(
  p: {
    publicClient: PublicClient;
    vault: Address;
    call: Call;
    note?: string;
    origin?: string;
    chainId?: number;
    /** Default true: refuse a call that reverts in simulation. */
    simulate?: boolean;
  } & NonceChoice,
): Promise<{
  proposal: ImportedProposalV1;
  json: string;
  link: string;
  safeTxHash: Hash;
  nonce: bigint;
  tx: SafeTx;
  simulation: Simulation | null;
}> {
  const chainId = p.chainId ?? KUB_CHAIN_ID;
  if ((await p.publicClient.getChainId()) !== chainId) throw new Error("wrong network");
  const vault = getAddress(p.vault);
  const info = await readVault(p.publicClient, vault);
  const nonce = await chooseNonce(p.publicClient, vault, info, p);
  const tx = buildSafeTx(p.call, nonce);
  if (tx.operation === 1 && !isAddressEqual(tx.to, MULTI_SEND_CALL_ONLY[info.version]))
    throw new Error(`Batches must use MultiSendCallOnly ${info.version}`);
  const simulation =
    p.simulate === false ? null : await checkSimulation(p.publicClient, vault, tx, info.version);
  const proposal = buildImportedProposal({
    vault,
    tx,
    chainId,
    ...(p.note ? { note: p.note } : {}),
    ...(p.origin ? { origin: p.origin } : {}),
  });
  return {
    proposal,
    json: JSON.stringify(proposal, null, 2),
    link: await proposalLink(proposal),
    safeTxHash: hashSafeTx(chainId, vault, tx),
    nonce,
    tx,
    simulation,
  };
}

/** One request, many calls: DELEGATECALL to the Vault version's MultiSendCallOnly; inner calls are CALLs. */
export function buildBatchCall(
  version: SafeVersion,
  calls: readonly { to: Address; value?: bigint; data?: Hex }[],
): Call {
  if (calls.length < 1 || calls.length > 50) throw new Error("a batch needs 1 to 50 calls");
  const packed = concat(
    calls.map((c) => {
      const data = c.data ?? "0x";
      return encodePacked(
        ["uint8", "address", "uint256", "uint256", "bytes"],
        [0, getAddress(c.to), c.value ?? 0n, BigInt(size(data)), data],
      );
    }),
  );
  return {
    to: MULTI_SEND_CALL_ONLY[version],
    value: 0n,
    data: encodeFunctionData({ abi: MULTI_SEND_ABI, functionName: "multiSend", args: [packed] }),
    operation: 1,
  };
}

const EXECUTION_SUCCESS = toEventSelector("ExecutionSuccess(bytes32,uint256)");
const EXECUTION_FAILURE = toEventSelector("ExecutionFailure(bytes32,uint256)");

export type RequestStatus =
  | { state: "pending"; approvals: Address[]; threshold: bigint; ready: boolean }
  | { state: "executed"; txHash: Hash; blockNumber: bigint }
  | { state: "failed"; txHash: Hash; blockNumber: bigint }
  /** The nonce was used by another transaction (for example a rejection). */
  | { state: "replaced" };

export async function getRequestStatus(
  client: PublicClient,
  vault: Address,
  req: { safeTxHash: Hash; nonce: bigint; fromBlock: bigint },
): Promise<RequestStatus> {
  const info = await readVault(client, vault);
  if (req.nonce >= info.nonce) {
    // Approvals are on-chain flags; only CURRENT owners count.
    const flags = await Promise.all(
      info.owners.map((o) =>
        client.readContract({
          address: vault,
          abi: SAFE_ABI,
          functionName: "approvedHashes",
          args: [o, req.safeTxHash],
        }),
      ),
    );
    const approvals = info.owners.filter((_, i) => flags[i] !== 0n);
    return {
      state: "pending",
      approvals,
      threshold: info.threshold,
      ready: req.nonce === info.nonce && BigInt(approvals.length) >= info.threshold,
    };
  }
  // ExecutionSuccess/Failure(bytes32 txHash, uint256 payment): txHash is indexed from 1.4.1, in data in 1.3.0.
  const logs = await client.request({
    method: "eth_getLogs",
    params: [
      {
        address: vault,
        fromBlock: numberToHex(req.fromBlock),
        toBlock: "latest",
        topics: [[EXECUTION_SUCCESS, EXECUTION_FAILURE]],
      },
    ],
  });
  for (const log of logs) {
    const hash = log.topics[1] ?? slice(log.data, 0, 32);
    if (hash.toLowerCase() !== req.safeTxHash.toLowerCase() || !log.transactionHash) continue;
    const at = { txHash: log.transactionHash, blockNumber: hexToBigInt(log.blockNumber ?? "0x0") };
    return log.topics[0] === EXECUTION_SUCCESS
      ? { state: "executed", ...at }
      : { state: "failed", ...at };
  }
  return { state: "replaced" };
}

/**
 * Anyone may execute once `threshold` current owners approved (getRequestStatus() → ready). Pass the request's
 * SafeTx (sendRequest().tx, or buildSafeTx(call, nonce)) and status.approvals. Signatures are v=1 "approved hash"
 * entries, sorted by owner address: r = owner, s = 0, v = 1. Send it to the Vault as a legacy transaction.
 */
export function encodeExecute(tx: SafeTx, approvers: readonly Address[]): Hex {
  const owners = [...new Set(approvers.map((a) => getAddress(a)))].sort((a, b) =>
    hexToBigInt(a) < hexToBigInt(b) ? -1 : 1,
  );
  const signatures = concat(
    owners.map((o) => encodePacked(["uint256", "uint256", "uint8"], [hexToBigInt(o), 0n, 1])),
  );
  return encodeFunctionData({
    abi: SAFE_ABI,
    functionName: "execTransaction",
    args: [
      tx.to,
      tx.value,
      tx.data,
      tx.operation,
      tx.safeTxGas,
      tx.baseGas,
      tx.gasPrice,
      tx.gasToken,
      tx.refundReceiver,
      signatures,
    ],
  });
}

Safe contracts on KUB Chain

Canonical Safe deployments on KUB mainnet, rendered from tougue.'s registry. tougue. checks each code hash on-chain before using a contract. Each row says whether its source is verified on KUB Scan.

Safe 1.5.0: what a dApp needs

Default for new Vaults

Source-verified on KUB Scan. Deployed via Multicall3 → Safe Singleton Factory in 0x43295f07…2239b06f (block 35,934,440).

  • SafeL2

    Singleton (logic) of every new Vault. Emits the events tougue. reads.

    0xEdd160fEBBD92E350D4D398fb636302fccd67C7eKUB Scan
    source verified on KUB Scan
    code hash0x180193227186ccb85316c94db1f0d156ed932b14712cfaac78901899178572dc
  • SafeProxyFactory

    Creates new Vaults (createProxyWithNonceL2).

    0x14F2982D601c9458F93bd70B218933A6f8165e7bKUB Scan
    source verified on KUB Scan
    code hash0x967dae4cda22b0c9ef7f31b010bdc1ceb0af9904b0c3dc060b5302e4c18a4529
  • CompatibilityFallbackHandler

    Default fallback handler: token callbacks, EIP-1271, simulation entry point.

    0x3EfCBb83A4A7AfcB4F68D501E2c2203a38be77f4KUB Scan
    source verified on KUB Scan
    code hash0x3c6a85bcf7b563daa624b884b4e9a1b9fa5371edde7be945d998071a48f28bbc
  • MultiSendCallOnly

    Batches of CALLs. The DELEGATECALL target to use for batch requests.

    0xA83c336B20401Af773B6219BA5027174338D1836KUB Scan
    source verified on KUB Scan
    code hash0xcdbdcec38d2f1c7d961b0029ff8416b7e86e9974d6f0e9c9580c7d17fcfb6663
  • SimulateTxAccessor

    Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.

    0x07EfA797c55B5DdE3698d876b277aBb6B893654CKUB Scan
    source verified on KUB Scan
    code hash0x706db4bb6151f75a5b3845724174e201c59046dfbe65b4e5909ad4b88f8f752d
All other contracts: the rest of Safe 1.5.0, Safe 1.4.1, Safe 1.3.0, infrastructure
  • MultiSend

    Batch contract that also allows inner DELEGATECALLs. tougue. accepts a DELEGATECALL to it only if every inner call is a CALL; send batches to MultiSendCallOnly.

    0x218543288004CD07832472D464648173c77D7eB7KUB Scan
    source verified on KUB Scan
    code hash0xca1147a12963172a93910c5cb2bfa5ad0e941c7f03fc7eb017dd06a8ea4e5604
  • Safe

    Singleton without L2 events. Not used for new Vaults.

    0xFf51A5898e281Db6DfC7855790607438dF2ca44bKUB Scan
    source verified on KUB Scan
    code hash0xdda019cbd7c867a533a2a86e5c53434fdc50b13122b5a5ddb4a8df61b31c20f2
  • SignMessageLib

    Message signing from a Vault. Canonical set; tougue. does not call it.

    0x4FfeF8222648872B3dE295Ba1e49110E61f5b5aaKUB Scan
    source verified on KUB Scan
    code hash0xd61840855da008da59a00fc03fb71455b4f70bdca1f56f9504f072ed8d90c50e
  • CreateCall

    Contract deployment from a Vault. Canonical set; tougue. does not call it.

    0x2Ef5ECfbea521449E4De05EDB1ce63B75eDA90B4KUB Scan
    source verified on KUB Scan
    code hash0x6b7d8d29bdf7004c4617d95041923774f3f7e74b056bff55c1861c9ec92ce54f
  • SafeMigration

    Singleton / handler migration. Canonical set; tougue. does not call it.

    0x6439e7ABD8Bb915A5263094784C5CF561c4172ACKUB Scan
    source verified on KUB Scan
    code hash0x52d7472fa02c3a574544f9b5a4ed4c7777e4e3315217e368926b51e1cc6014ea
  • SafeToL2Setup

    Switches a Safe to SafeL2 at setup. Canonical set; tougue. does not call it.

    0x900C7589200010D6C6eCaaE5B06EBe653bc2D82aKUB Scan
    source verified on KUB Scan
    code hash0xf6034d841bcbff8912aa55526b0f1609212536aaf60bb16f5e8a269a4ab38f18
  • ExtensibleFallbackHandler

    Optional extensible fallback handler. Recognised in Vault settings.

    0x85a8ca358D388530ad0fB95D0cb89Dd44Fc242c3KUB Scan
    source verified on KUB Scan
    code hash0xba5bafdfba82e226b6dc8ae29bedf5026bd854ab4bee00128ca322717a5f2acf
  • TokenCallbackHandler

    Optional ERC-721 / ERC-1155 receiver handler. Recognised in Vault settings.

    0x54e86d004d71a8D2112ec75FaCE57D730b0433F3KUB Scan
    source verified on KUB Scan
    code hash0xcbc723172700efa52cc33ee26c7fc7e284edc8097f9dc307857fe525fec98cd8

Safe 1.4.1

Supported

No source uploaded on KUB Scan. The runtime code hash of each contract equals the official build, and tougue. checks it on-chain.

  • SafeL2

    Singleton of Vaults created with this version.

    0x29fcB43b46531BcA003ddC8FCB67FFE91900C762KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff
  • SafeProxyFactory

    Created Vaults of this version (still readable).

    0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x50c3cdc4074750a7a974204a716c999edd37482f907608d960b2b025ee0b3317
  • CompatibilityFallbackHandler

    Default fallback handler: token callbacks, EIP-1271, simulation entry point.

    0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x7c6007a5d711cea8dfd5d91f5940ec29c7f200fe511eb1fc1397b367af3c42f9
  • MultiSendCallOnly

    Batches of CALLs. The DELEGATECALL target to use for batch requests.

    0x9641d764fc13c8B624c04430C7356C1C7C8102e2KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0xecd5bd14a08c5d2122379900b2f272bdf107a7e92423c10dd5fe3254386c9939
  • MultiSend

    Batch contract that also allows inner DELEGATECALLs. tougue. accepts a DELEGATECALL to it only if every inner call is a CALL; send batches to MultiSendCallOnly.

    0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x0e4f7fc66550a322d1e7688e181b75e217e662a4f3f4d6a29b22bc61217c4b77
  • SimulateTxAccessor

    Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.

    0x3d4BA2E0884aa488718476ca2FB8Efc291A46199KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x91f82615581fc73b190b83d72e883608b25e392f72322035df1b13d51766cf8d
  • Safe

    Singleton without L2 events. Not used for new Vaults.

    0x41675C099F32341bf84BFc5382aF534df5C7461aKUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x1fe2df852ba3299d6534ef416eefa406e56ced995bca886ab7a553e6d0c5e1c4
  • SignMessageLib

    Message signing from a Vault. Canonical set; tougue. does not call it.

    0xd53cd0aB83D845Ac265BE939c57F53AD838012c9KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x525c754a46b79e05543a59bb61e8de3c9eee0d955a59352409cbe67ea1077528
  • CreateCall

    Contract deployment from a Vault. Canonical set; tougue. does not call it.

    0x9b35Af71d77eaf8d7e40252370304687390A1A52KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0x2b3060c55fcb8275653e99ad511a71f67ba76934ed66a7d74d6e68b52afff889

Safe 1.3.0

Supported (existing Vaults)

Source-verified on KUB Scan.

  • GnosisSafeL2

    Singleton of Vaults created with this version.

    0x3E5c63644E683549055b9Be8653de26E0B4CD36EKUB Scan
    source verified on KUB Scan
    code hash0x21842597390c4c6e3c1239e434a682b054bd9548eee5e9b1d6a4482731023c0f
  • GnosisSafeProxyFactory

    Created Vaults of this version (still readable).

    0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2KUB Scan
    source verified on KUB Scan
    code hash0x337d7f54be11b6ed55fef7b667ea5488db53db8320a05d1146aa4bd169a39a9b
  • CompatibilityFallbackHandler

    Default fallback handler: token callbacks, EIP-1271, simulation entry point.

    0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4KUB Scan
    source verified on KUB Scan
    code hash0x03e69f7ce809e81687c69b19a7d7cca45b6d551ffdec73d9bb87178476de1abf
  • MultiSendCallOnly

    Batches of CALLs. The DELEGATECALL target to use for batch requests.

    0x40A2aCCbd92BCA938b02010E17A5b8929b49130DKUB Scan
    source verified on KUB Scan
    code hash0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad
  • MultiSend

    Batch contract that also allows inner DELEGATECALLs. tougue. accepts a DELEGATECALL to it only if every inner call is a CALL; send batches to MultiSendCallOnly.

    0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761KUB Scan
    source verified on KUB Scan
    code hash0x0208282bd262360d0320862c5ac70f375f5ed3b9d89a83a615b4d398415bdc83
  • SimulateTxAccessor

    Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.

    0x59AD6735bCd8152B84860Cb256dD9e96b85F69DaKUB Scan
    source verified on KUB Scan
    code hash0xb3fb9763869f2c09a2ac5a425d2dd6060bf7ef46b3899049d71a711e71e00f04
  • GnosisSafe

    Singleton without L2 events. Not used for new Vaults.

    0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552KUB Scan
    source verified on KUB Scan
    code hash0xbba688fbdb21ad2bb58bc320638b43d94e7d100f6f3ebaab0a4e4de6304b1c2e
  • SignMessageLib

    Message signing from a Vault. Canonical set; tougue. does not call it.

    0xA65387F16B013cf2Af4605Ad8aA5ec25a2cbA3a2KUB Scan
    source verified on KUB Scan
    code hash0x3ac65dea3cc9dd0d7b7b800f834e3d73415b4e944bb94555c3e4a08fb137e918
  • CreateCall

    Contract deployment from a Vault. Canonical set; tougue. does not call it.

    0x7cbB62EaA69F79e6873cD1ecB2392971036cFAa4KUB Scan
    source verified on KUB Scan
    code hash0x8155d988823a4f6f1bcbc76a64af8e510c4ce68819290d43cf24956bd24dee82
  • Safe Singleton Factory

    CREATE2 deployer that placed Safe 1.4.1 and 1.5.0 at their canonical addresses.

    0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7KUB Scan
    no source on KUB Scan
  • Multicall3

    Batched reads (balances, approvals). Read-only for tougue. Also batched the Safe 1.5.0 deployment.

    0xcA11bde05977b3631167028862bE2a173976CA11KUB Scan
    no source on KUB Scan · code hash equals the official build
    code hash0xd5c15df687b16f2ff992fc8d767b4216323184a2bbc6ee2f9c398c318e770891

tougue. works with any ERC-20 token on KUB. The tokens below are shown with a logo and a Verified mark; add any other with + Other asset or Scan tokens in Assets.

  • KKUBVerified

    Wrapped KUB · 18 decimals

    0x67eBD850304c70d983B2d1b93ea79c7CD6c3F6b5KUB Scan
  • USDTVerified

    Bridged Tether USD · 6 decimals

    0x21CdC3706B8C7B1836Df0E533Dd884069521350BKUB Scan
  • USDCVerifiedUSDC.e

    Bridged USDC (KUBChain) · 6 decimals

    0x31929a0fd776F971C5dd14bF03e1F9fF69D9c91cKUB Scan
  • USDT (Tether USD)VerifiedUSDT

    Tether USD · 6 decimals

    0xE5DEC503CD76CE8777cb2FfD8ffe0a15DB10ec38KUB Scan
  • KUSDCVerified

    Bitkub-Peg USDC · 18 decimals

    0x77071ad51ca93fc90e77BCdECE5aa6F1B40fcb21KUB Scan
  • KUSDTVerified

    Bitkub-Peg USDT · 18 decimals

    0x7d984C24d2499D840eB3b7016077164e15E5faA6KUB Scan
  • KBTCVerified

    Bitkub-Peg BTC · 18 decimals

    0x726613C4494C60B7dCdeA5BE2846180C1DAfBE8BKUB Scan

Two USDT contracts exist: match the address, not the symbol. Any other token using these symbols is flagged.

FAQ

How much gas does a request use?

A request or an approval is one approveHash transaction: about 50k gas, ≈0.0012 KUB at 25 gwei. Execution costs the gas of the call itself. tougue. adds no fee on top.

How do I verify it?

Every Safe contract listed is an official, unmodified build. Safe 1.5.0 and 1.3.0 are source-verified on KUB Scan. Safe 1.4.1 has no source uploaded there, but each contract's runtime code hash equals the official build; every row above says which. tougue. compares each contract's runtime code hash on-chain before use, verifies every Vault before showing it, and re-hashes every request. There is no server and tougue. never holds keys.

How does tougue. relate to Safe?

tougue. is powered by Safe Protocol: it runs the official, unmodified Safe contracts. It is not a fork and changes no contract code. The interface is independent and contains no Safe{Wallet} code. Vaults are standard Safes, so any Safe-compatible tool that supports KUB Chain can read them. Not affiliated with or endorsed by Safe Ecosystem Foundation or Safe Labs GmbH. Safe is a trademark of its owner.

Do I need an API key or a backend?

No. Read the Vault from any KUB RPC and publish requests on-chain. tougue. finds them from the Vault's events.

Wallets, Google sign-in, fees and where Vault names are kept: general FAQ.

Safe smart-account source