import { MonacoSDK } from "@0xmonaco/core";
import {
concat,
createPublicClient,
createWalletClient,
getAddress,
hashMessage,
hashTypedData,
http,
parseAbi,
type Hex,
} from "viem";
import { privateKeyToAccount, toAccount } from "viem/accounts";
import { sei } from "viem/chains";
const SAFE = getAddress(process.env.SAFE_ADDRESS!);
const CLIENT_ID = process.env.MONACO_CLIENT_ID!;
const SEI_RPC_URL = "https://evm-rpc.sei-apis.com";
const SAFE_TX_SERVICE = "https://transaction.sei-safe.protofire.io";
// One Safe owner. It proposes each login message and sends the final
// withdrawal transaction, so it needs a little SEI for gas.
const owner = privateKeyToAccount(process.env.OWNER_PRIVATE_KEY as Hex);
const publicClient = createPublicClient({ chain: sei, transport: http(SEI_RPC_URL) });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const wholeNumber = (name: string): number => {
const value = process.env[name] ?? "";
if (!/^\d+$/.test(value)) throw new Error(`${name} must be a whole number`);
return Number(value);
};
// Monaco's login asks the wallet to sign a text message. A Safe "signs" it
// when enough owners sign the Safe's own EIP-712 SafeMessage over it.
async function signAsSafe(message: string): Promise<Hex> {
const typedData = {
domain: { chainId: sei.id, verifyingContract: SAFE },
types: { SafeMessage: [{ name: "message", type: "bytes" }] },
primaryType: "SafeMessage",
message: { message: hashMessage(message) },
} as const;
const safeMessageHash = hashTypedData(typedData);
const threshold = Number(
await publicClient.readContract({
address: SAFE,
abi: parseAbi(["function getThreshold() view returns (uint256)"]),
functionName: "getThreshold",
}),
);
const proposed = await fetch(`${SAFE_TX_SERVICE}/api/v1/safes/${SAFE}/messages/`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message, signature: await owner.signTypedData(typedData), safeAppId: null }),
});
if (!proposed.ok) throw new Error(`Safe Transaction Service: ${proposed.status} ${await proposed.text()}`);
console.log(`Ask the other owners to confirm message ${safeMessageHash} in the Safe app, under Messages.`);
// Monaco's login challenge is valid for 30 minutes.
const deadline = Date.now() + 29 * 60_000;
while (Date.now() < deadline) {
const response = await fetch(`${SAFE_TX_SERVICE}/api/v1/messages/${safeMessageHash}/`);
if (response.ok) {
const { confirmations } = (await response.json()) as {
confirmations: { owner: string; signature: Hex }[];
};
console.log(`${confirmations.length} of ${threshold} owners have confirmed`);
if (confirmations.length >= threshold) {
// The Safe wants exactly `threshold` signatures, ordered by owner address.
return concat(
confirmations
.sort((a, b) => (BigInt(a.owner) < BigInt(b.owner) ? -1 : 1))
.slice(0, threshold)
.map((c) => c.signature),
);
}
}
await sleep(5_000);
}
throw new Error("The login challenge expired before enough owners confirmed. Run the script again.");
}
const safeAccount = toAccount({
address: SAFE,
signMessage: ({ message }) => signAsSafe(message as string),
signTransaction: () => Promise.reject(new Error("Not used")),
signTypedData: () => Promise.reject(new Error("Not used")),
});
const sdk = new MonacoSDK({
network: "mainnet",
seiRpcUrl: SEI_RPC_URL,
walletClient: createWalletClient({ account: safeAccount, chain: sei, transport: http(SEI_RPC_URL) }),
});
async function main() {
// Finishing a started withdrawal needs no login: the lookup is public.
if (process.env.WITHDRAWAL_INDEX !== undefined) {
await finishWithdrawal(wholeNumber("WITHDRAWAL_INDEX"));
return;
}
await sdk.login(CLIENT_ID);
try {
await run();
} finally {
// End the session so the owners' approval cannot be reused.
await sdk.logout();
}
}
async function run() {
// 1. What you have earned, and what is waiting to be withdrawn.
const config = await sdk.buildercodes.getConfig();
console.log(`Builder fee: taker ${config.builderFeeBps} bps, maker ${config.builderMakerFeeBps ?? 0} bps`);
const summary = await sdk.buildercodes.getPayoutSummary();
for (const row of summary.byTokenAndKind ?? []) {
console.log(`Earned ${row.kind} in ${row.token}: ${row.totalAmount} (raw units)`);
}
const { balances } = await sdk.buildercodes.getRewardsBalance();
for (const balance of balances) {
console.log(`Available to withdraw in ${balance.token}: ${balance.available} (raw units)`);
}
// 2. Optional: change your fees. Two changes per rolling 24 hours.
// Leaving out MAKER_BPS keeps the current maker fee.
if (process.env.TAKER_BPS !== undefined) {
const result = await sdk.buildercodes.setBuilderFee({
bps: wholeNumber("TAKER_BPS"),
...(process.env.MAKER_BPS !== undefined && { makerBps: wholeNumber("MAKER_BPS") }),
});
console.log(`Fees now taker ${result.builderFeeBps} bps, maker ${result.builderMakerFeeBps} bps`);
}
// 3. Optional: withdraw all of the Safe's USDC on Monaco to the Safe.
if (process.env.WITHDRAW === "1") {
// Move any waiting earnings into the Safe's Monaco balance first (no gas).
for (const balance of balances) {
if (BigInt(balance.available) > 0n) {
await sdk.buildercodes.claimBuildercodeRewards({ token: balance.token, amount: balance.available });
}
}
const { balances: accountBalances } = await sdk.profile.getUserBalances({ pageSize: 100 });
const usdc = accountBalances.find((b) => b.symbol === "USDC");
if (!usdc || BigInt(usdc.availableBalanceRaw) === 0n) throw new Error("No USDC to withdraw");
const { withdrawalIndex } = await sdk.withdrawals.initiateWithdrawal({
assetId: usdc.assetId,
amount: usdc.availableBalanceRaw,
destination: SAFE,
});
console.log(`Withdrawal ${withdrawalIndex} started. If this script stops, finish it with WITHDRAWAL_INDEX=${withdrawalIndex}.`);
await finishWithdrawal(withdrawalIndex);
}
}
async function finishWithdrawal(withdrawalIndex: number) {
console.log(`Waiting for withdrawal ${withdrawalIndex} to be ready on-chain...`);
const deadline = Date.now() + 60 * 60_000;
while (Date.now() < deadline) {
// 404 or 409 means not confirmed on-chain yet; any other error will not fix itself.
const ready = await sdk.withdrawals.getWithdrawal(withdrawalIndex).catch((error) => {
if (error?.statusCode === 404 || error?.statusCode === 409) return undefined;
throw error;
});
if (ready?.calldata) {
// Any wallet may send this; the funds always go to the Safe.
const executor = createWalletClient({ account: owner, chain: sei, transport: http(SEI_RPC_URL) });
const hash = await executor.sendTransaction({ to: ready.vaultAddress, data: ready.calldata as Hex });
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
throw new Error(`Transaction ${hash} failed. Finish later with WITHDRAWAL_INDEX=${withdrawalIndex}.`);
}
console.log(`Withdrawn to ${SAFE} in transaction ${hash}`);
return;
}
await sleep(20_000);
}
throw new Error(`Withdrawal ${withdrawalIndex} is not ready yet. Finish it later with WITHDRAWAL_INDEX=${withdrawalIndex}.`);
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
// The Monaco SDK keeps connections open; exit once the work is done.
.finally(() => process.exit());