# tougue. integration guide

**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.

Use cases: 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.
>
> Source: https://tougue.xyz/tougue-integration.md · Docs: https://tougue.xyz/docs/ · Generated from the tested sources of the docs page.

## What tougue. is

tougue. is multisig Vaults on KUB Chain (chain 96) running the official, unmodified Safe contracts: Safe 1.5.0 for new Vaults, 1.4.1 and 1.3.0 supported. There is no backend and no API: the queue is on-chain. Any owner proposes a transaction by calling `approveHash(safeTxHash)` on the Vault with the full SafeTx appended to the calldata; tougue. reads it back from the Vault's events, re-hashes it and shows it to every owner. The other owners approve with `approveHash`, and once the threshold is met anyone can execute.

Flow: **1 Propose** (owner: `approveHash(h)` + payload) → **2 Approve** (other owners: `approveHash(h)`) → **3 Execute** (anyone: `execTransaction`) → the Vault calls the target. `h` = safeTxHash.

## Use this file with an AI coding agent

- Add this file to your repo (or paste it into Cursor, Claude Code, Codex, Copilot) as context for the task.
- Copy `tougue-request.ts` (full source at the end of this file) into your project unchanged. It depends on viem 2.x only.
- Your signer is a Vault owner (EOA): use **Path A**. Otherwise, or with a smart-account owner: use **Path B**.
- Never ask the user for private keys or seed phrases. Every transaction is signed in the user's own wallet.
- Follow **Rules & limits**; tougue. ignores or blocks anything else.

## Chain facts

- **Chain**: KUB Mainnet (formerly Bitkub Chain), chainId `96`. Native coin KUB, 18 decimals.
- **RPC**: `https://rpc.bitkubchain.io`, `https://rpc.kubchain.io`
- **Explorer**: https://www.kubscan.com
- **Transactions**: legacy (type 0) only. No EIP-1559: always set an explicit `gasPrice` (`eth_gasPrice`).
- **Gas**: 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.
- **Safe versions**: 1.5.0 (new Vaults), 1.4.1, 1.3.0. Read `VERSION()` on the Vault.

## Contracts

Canonical Safe 1.5.0 deployments on KUB (official builds, source-verified on KUB Scan), plus infrastructure.

| Contract                     | Address                                      | Role                                                                                               |
| ---------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| SafeL2                       | `0xEdd160fEBBD92E350D4D398fb636302fccd67C7e` | Singleton (logic) of every new Vault. Emits the events tougue. reads.                              |
| SafeProxyFactory             | `0x14F2982D601c9458F93bd70B218933A6f8165e7b` | Creates new Vaults (createProxyWithNonceL2).                                                       |
| CompatibilityFallbackHandler | `0x3EfCBb83A4A7AfcB4F68D501E2c2203a38be77f4` | Default fallback handler: token callbacks, EIP-1271, simulation entry point.                       |
| MultiSendCallOnly            | `0xA83c336B20401Af773B6219BA5027174338D1836` | Batches of CALLs. The DELEGATECALL target to use for batch requests.                               |
| SimulateTxAccessor           | `0x07EfA797c55B5DdE3698d876b277aBb6B893654C` | Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.                        |
| Safe Singleton Factory       | `0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7` | CREATE2 deployer that placed Safe 1.4.1 and 1.5.0 at their canonical addresses.                    |
| Multicall3                   | `0xcA11bde05977b3631167028862bE2a173976CA11` | Batched reads (balances, approvals). Read-only for tougue. Also batched the Safe 1.5.0 deployment. |

Batch targets for older Vault versions: see **Batches**. Full set with code hashes: https://tougue.xyz/docs/#contracts

## Concepts

- **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.

## Path A: your signer is an owner (recommended)

Publish the request on-chain from your app with `sendRequest()` from `tougue-request.ts`.

1. Read 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. Pick the next free nonce, after every pending request (pass `fromBlock`).
3. Build the SafeTx: a CALL with every gas and refund field zero.
4. Hash it twice, offline (EIP-712) and with the Vault's `getTransactionHash`. They must match.
5. Encode `approveHash(safeTxHash)` ‖ `0x544f554e47` ‖ `0x01` ‖ `abi.encode(…)`.
6. Simulate the call as the Vault (`simulateAndRevert` + `SimulateTxAccessor`, an eth_call). The helper refuses to publish a call that reverts.
7. Estimate, then send `approveHash` to the Vault as a **legacy** transaction with an explicit `gasPrice`.
8. Check the receipt: from you, to the Vault, with `ApproveHash(safeTxHash, you)`. Then send the other owners the request link.

```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 for the other owners: `https://tougue.xyz/vault/tx/?safe=kub:<vault>&hash=<safeTxHash>`

Wire format (v1):

```text
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.

## Path B: you are not an owner

Build an ImportedProposal with `buildOwnerRequest()` and hand the link (or JSON) to an owner.

1. Pick the next free nonce, after every request already on-chain (public reads, no wallet).
2. Build the SafeTx, simulate it as the Vault, and build the ImportedProposal JSON.
3. Give an owner the link (or the JSON file).
4. The 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).

```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);
```

Deep link: `https://tougue.xyz/open/#proposal=<base64url(deflate-raw(JSON)), no padding>`

proposal.json (placeholder addresses):

```json
{
  "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 `nonce`s.
- The #fragment never reaches a server. Very large calldata: send the JSON file.

Optional button for your UI (a React version is on the docs page):

```html
<a href="https://tougue.xyz/open/#proposal=…" target="_blank" rel="noopener noreferrer">
  Request in tougue.
</a>
```

## Batches

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.

| Vault version | MultiSendCallOnly                            |
| ------------- | -------------------------------------------- |
| Safe 1.5.0    | `0xA83c336B20401Af773B6219BA5027174338D1836` |
| Safe 1.4.1    | `0x9641d764fc13c8B624c04430C7356C1C7C8102e2` |
| Safe 1.3.0    | `0x40A2aCCbd92BCA938b02010E17A5b8929b49130D` |

```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);
```

## Track status

- **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.

```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.

## Execute

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.

```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(),
  });
}
```

## Test first

- **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.

## Rules & limits

- **`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:

- **`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.

## Tokens

tougue. works with **any ERC-20 token** on KUB: nothing to register or allowlist. A Vault can hold, send, approve or call any token contract. The addresses below are convenience references only (tougue. shows them with a logo and a Verified mark).

| Token             | On-chain symbol | Address                                      | Decimals |
| ----------------- | --------------- | -------------------------------------------- | -------- |
| KKUB              | `KKUB`          | `0x67eBD850304c70d983B2d1b93ea79c7CD6c3F6b5` | 18       |
| USDT              | `USDT`          | `0x21CdC3706B8C7B1836Df0E533Dd884069521350B` | 6        |
| USDC              | `USDC.e`        | `0x31929a0fd776F971C5dd14bF03e1F9fF69D9c91c` | 6        |
| USDT (Tether USD) | `USDT`          | `0xE5DEC503CD76CE8777cb2FfD8ffe0a15DB10ec38` | 6        |
| KUSDC             | `KUSDC`         | `0x77071ad51ca93fc90e77BCdECE5aa6F1B40fcb21` | 18       |
| KUSDT             | `KUSDT`         | `0x7d984C24d2499D840eB3b7016077164e15E5faA6` | 18       |
| KBTC              | `KBTC`          | `0x726613C4494C60B7dCdeA5BE2846180C1DAfBE8B` | 18       |

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

## Links

- App: https://tougue.xyz
- Docs: https://tougue.xyz/docs/
- This file: https://tougue.xyz/tougue-integration.md
- Explorer: https://www.kubscan.com
- Safe contracts source: https://github.com/safe-fndn/safe-smart-account

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

## tougue-request.ts (full source)

Copy unchanged. viem 2.x only; browsers and Node 20.12+. tougue.'s test suite executes this exact file against its own decoder and import parser.

```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,
    ],
  });
}
```
