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.?
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
On-chain queue
A request is an owner's
approveHashwith 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.
- Chain facts
- Contracts
- Path A / B
- viem helper
- Rules
- Any ERC-20
KUB Chain multisig SDK: send a request to a Safe Vault on KUB
For dApps whose protocol or treasury is controlled by a customer's Vault. No API, no inbox: a request exists once it is on-chain, and only the Vault's owners can approve it. You need viem and nothing else.
- 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 app's signer is an owner
Recommended- 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. - 2Pick the next free nonce, after every pending request (pass
fromBlock). - 3Build the SafeTx: a CALL with every gas and refund field zero.
- 4Hash it twice, offline (EIP-712) and with the Vault's
getTransactionHash. They must match. - 5Encode
approveHash(safeTxHash)‖0x544f554e47‖0x01‖abi.encode(…). - 6Simulate the call as the Vault (
simulateAndRevert+SimulateTxAccessor, an eth_call). The helper refuses to publish a call that reverts. - 7Estimate, then send
approveHashto the Vault as a legacy transaction with an explicitgasPrice. - 8Check the receipt: from you, to the Vault, with
ApproveHash(safeTxHash, you). Then send the other owners the request link.
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);https://tougue.xyz/vault/tx/?safe=kub:<vault>&hash=<safeTxHash>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
/** 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 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()}`;
}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;
}- 1Pick the next free nonce, after every request already on-chain (public reads, no wallet).
- 2Build the SafeTx, simulate it as the Vault, and build the ImportedProposal JSON.
- 3Give an owner the link (or the JSON file).
- 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).
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);https://tougue.xyz/open/#proposal=<base64url(deflate-raw(JSON)), no padding>{
"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
versionandoperation. typestays"toung.proposal"(wire value). Max 300,000 bytes.safeTxHashis optional; if present it must match.originis 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
/** 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
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>
);
}<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
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);/** 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 withapprovedHashes(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.
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.
}Inside 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.
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()
/**
* 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
chainIdtosendRequest()/buildOwnerRequest()and your own viem chain.urlis thennull. - 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?0nworks on KUB.
| What to send | Why |
|---|---|
operation = 0 (CALL), or a DELEGATECALL to this version's MultiSendCallOnlyDELEGATECALL runs foreign code as the Vault. Batches go through MultiSendCallOnly, whose inner calls are always CALLs. | DELEGATECALL runs foreign code as the Vault. Batches go through MultiSendCallOnly, whose inner calls are always CALLs. |
safeTxGas, baseGas, gasPrice = 0; gasToken, refundReceiver = 0x0Execution stays atomic (a failing call reverts) and the Vault never pays a refund. | 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. | 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). | 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 | 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. | 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. | 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 | 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. | 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. | Every transaction is signed in the user's own wallet. |
What tougue. enforces
| Check | Why |
|---|---|
gasPrice, gasToken or refundReceiver setBlocked: the Vault would pay the executor. | Blocked: the Vault would pay the executor. |
safeTxGas ≠ 0Flagged on an on-chain request (a failing call would not revert and still uses the nonce). On import, | 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. | 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 | 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. | 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. | 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.
Show the full file
// 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 VaultsSource-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.
source verified on KUB Scan0xEdd160fEBBD92E350D4D398fb636302fccd67C7eKUB Scancode hash0x180193227186ccb85316c94db1f0d156ed932b14712cfaac78901899178572dcSafeProxyFactory
Creates new Vaults (createProxyWithNonceL2).
source verified on KUB Scan0x14F2982D601c9458F93bd70B218933A6f8165e7bKUB Scancode hash0x967dae4cda22b0c9ef7f31b010bdc1ceb0af9904b0c3dc060b5302e4c18a4529CompatibilityFallbackHandler
Default fallback handler: token callbacks, EIP-1271, simulation entry point.
source verified on KUB Scan0x3EfCBb83A4A7AfcB4F68D501E2c2203a38be77f4KUB Scancode hash0x3c6a85bcf7b563daa624b884b4e9a1b9fa5371edde7be945d998071a48f28bbcMultiSendCallOnly
Batches of CALLs. The DELEGATECALL target to use for batch requests.
source verified on KUB Scan0xA83c336B20401Af773B6219BA5027174338D1836KUB Scancode hash0xcdbdcec38d2f1c7d961b0029ff8416b7e86e9974d6f0e9c9580c7d17fcfb6663SimulateTxAccessor
Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.
source verified on KUB Scan0x07EfA797c55B5DdE3698d876b277aBb6B893654CKUB Scancode hash0x706db4bb6151f75a5b3845724174e201c59046dfbe65b4e5909ad4b88f8f752d
All other contracts: the rest of Safe 1.5.0, Safe 1.4.1, Safe 1.3.0, infrastructure
Safe 1.5.0: rest of the canonical set
Default for new VaultsMultiSend
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.
source verified on KUB Scan0x218543288004CD07832472D464648173c77D7eB7KUB Scancode hash0xca1147a12963172a93910c5cb2bfa5ad0e941c7f03fc7eb017dd06a8ea4e5604Safe
Singleton without L2 events. Not used for new Vaults.
source verified on KUB Scan0xFf51A5898e281Db6DfC7855790607438dF2ca44bKUB Scancode hash0xdda019cbd7c867a533a2a86e5c53434fdc50b13122b5a5ddb4a8df61b31c20f2SignMessageLib
Message signing from a Vault. Canonical set; tougue. does not call it.
source verified on KUB Scan0x4FfeF8222648872B3dE295Ba1e49110E61f5b5aaKUB Scancode hash0xd61840855da008da59a00fc03fb71455b4f70bdca1f56f9504f072ed8d90c50eCreateCall
Contract deployment from a Vault. Canonical set; tougue. does not call it.
source verified on KUB Scan0x2Ef5ECfbea521449E4De05EDB1ce63B75eDA90B4KUB Scancode hash0x6b7d8d29bdf7004c4617d95041923774f3f7e74b056bff55c1861c9ec92ce54fSafeMigration
Singleton / handler migration. Canonical set; tougue. does not call it.
source verified on KUB Scan0x6439e7ABD8Bb915A5263094784C5CF561c4172ACKUB Scancode hash0x52d7472fa02c3a574544f9b5a4ed4c7777e4e3315217e368926b51e1cc6014eaSafeToL2Setup
Switches a Safe to SafeL2 at setup. Canonical set; tougue. does not call it.
source verified on KUB Scan0x900C7589200010D6C6eCaaE5B06EBe653bc2D82aKUB Scancode hash0xf6034d841bcbff8912aa55526b0f1609212536aaf60bb16f5e8a269a4ab38f18ExtensibleFallbackHandler
Optional extensible fallback handler. Recognised in Vault settings.
source verified on KUB Scan0x85a8ca358D388530ad0fB95D0cb89Dd44Fc242c3KUB Scancode hash0xba5bafdfba82e226b6dc8ae29bedf5026bd854ab4bee00128ca322717a5f2acfTokenCallbackHandler
Optional ERC-721 / ERC-1155 receiver handler. Recognised in Vault settings.
source verified on KUB Scan0x54e86d004d71a8D2112ec75FaCE57D730b0433F3KUB Scancode hash0xcbc723172700efa52cc33ee26c7fc7e284edc8097f9dc307857fe525fec98cd8
Safe 1.4.1
SupportedNo 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.
no source on KUB Scan · code hash equals the official build0x29fcB43b46531BcA003ddC8FCB67FFE91900C762KUB Scancode hash0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ffSafeProxyFactory
Created Vaults of this version (still readable).
no source on KUB Scan · code hash equals the official build0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67KUB Scancode hash0x50c3cdc4074750a7a974204a716c999edd37482f907608d960b2b025ee0b3317CompatibilityFallbackHandler
Default fallback handler: token callbacks, EIP-1271, simulation entry point.
no source on KUB Scan · code hash equals the official build0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99KUB Scancode hash0x7c6007a5d711cea8dfd5d91f5940ec29c7f200fe511eb1fc1397b367af3c42f9MultiSendCallOnly
Batches of CALLs. The DELEGATECALL target to use for batch requests.
no source on KUB Scan · code hash equals the official build0x9641d764fc13c8B624c04430C7356C1C7C8102e2KUB Scancode hash0xecd5bd14a08c5d2122379900b2f272bdf107a7e92423c10dd5fe3254386c9939MultiSend
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.
no source on KUB Scan · code hash equals the official build0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526KUB Scancode hash0x0e4f7fc66550a322d1e7688e181b75e217e662a4f3f4d6a29b22bc61217c4b77SimulateTxAccessor
Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.
no source on KUB Scan · code hash equals the official build0x3d4BA2E0884aa488718476ca2FB8Efc291A46199KUB Scancode hash0x91f82615581fc73b190b83d72e883608b25e392f72322035df1b13d51766cf8dSafe
Singleton without L2 events. Not used for new Vaults.
no source on KUB Scan · code hash equals the official build0x41675C099F32341bf84BFc5382aF534df5C7461aKUB Scancode hash0x1fe2df852ba3299d6534ef416eefa406e56ced995bca886ab7a553e6d0c5e1c4SignMessageLib
Message signing from a Vault. Canonical set; tougue. does not call it.
no source on KUB Scan · code hash equals the official build0xd53cd0aB83D845Ac265BE939c57F53AD838012c9KUB Scancode hash0x525c754a46b79e05543a59bb61e8de3c9eee0d955a59352409cbe67ea1077528CreateCall
Contract deployment from a Vault. Canonical set; tougue. does not call it.
no source on KUB Scan · code hash equals the official build0x9b35Af71d77eaf8d7e40252370304687390A1A52KUB Scancode hash0x2b3060c55fcb8275653e99ad511a71f67ba76934ed66a7d74d6e68b52afff889
Safe 1.3.0
Supported (existing Vaults)Source-verified on KUB Scan.
GnosisSafeL2
Singleton of Vaults created with this version.
source verified on KUB Scan0x3E5c63644E683549055b9Be8653de26E0B4CD36EKUB Scancode hash0x21842597390c4c6e3c1239e434a682b054bd9548eee5e9b1d6a4482731023c0fGnosisSafeProxyFactory
Created Vaults of this version (still readable).
source verified on KUB Scan0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2KUB Scancode hash0x337d7f54be11b6ed55fef7b667ea5488db53db8320a05d1146aa4bd169a39a9bCompatibilityFallbackHandler
Default fallback handler: token callbacks, EIP-1271, simulation entry point.
source verified on KUB Scan0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4KUB Scancode hash0x03e69f7ce809e81687c69b19a7d7cca45b6d551ffdec73d9bb87178476de1abfMultiSendCallOnly
Batches of CALLs. The DELEGATECALL target to use for batch requests.
source verified on KUB Scan0x40A2aCCbd92BCA938b02010E17A5b8929b49130DKUB Scancode hash0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066adMultiSend
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.
source verified on KUB Scan0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761KUB Scancode hash0x0208282bd262360d0320862c5ac70f375f5ed3b9d89a83a615b4d398415bdc83SimulateTxAccessor
Simulates a SafeTx (simulateAndRevert) before publish, approve and execute.
source verified on KUB Scan0x59AD6735bCd8152B84860Cb256dD9e96b85F69DaKUB Scancode hash0xb3fb9763869f2c09a2ac5a425d2dd6060bf7ef46b3899049d71a711e71e00f04GnosisSafe
Singleton without L2 events. Not used for new Vaults.
source verified on KUB Scan0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552KUB Scancode hash0xbba688fbdb21ad2bb58bc320638b43d94e7d100f6f3ebaab0a4e4de6304b1c2eSignMessageLib
Message signing from a Vault. Canonical set; tougue. does not call it.
source verified on KUB Scan0xA65387F16B013cf2Af4605Ad8aA5ec25a2cbA3a2KUB Scancode hash0x3ac65dea3cc9dd0d7b7b800f834e3d73415b4e944bb94555c3e4a08fb137e918CreateCall
Contract deployment from a Vault. Canonical set; tougue. does not call it.
source verified on KUB Scan0x7cbB62EaA69F79e6873cD1ecB2392971036cFAa4KUB Scancode hash0x8155d988823a4f6f1bcbc76a64af8e510c4ce68819290d43cf24956bd24dee82
Safe Singleton Factory
CREATE2 deployer that placed Safe 1.4.1 and 1.5.0 at their canonical addresses.
no source on KUB Scan0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7KUB ScanMulticall3
Batched reads (balances, approvals). Read-only for tougue. Also batched the Safe 1.5.0 deployment.
no source on KUB Scan · code hash equals the official build0xcA11bde05977b3631167028862bE2a173976CA11KUB Scancode 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
USDCVerified
USDC.eBridged USDC (KUBChain) · 6 decimals
0x31929a0fd776F971C5dd14bF03e1F9fF69D9c91cKUB Scan
USDT (Tether USD)Verified
USDTTether 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.