> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xmonaco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# BuilderCodes with a Safe

> Use a Safe multisig as your BuilderCodes payout wallet: check your earnings, set your fees, and withdraw to the Safe

Your frontend's BuilderCodes earnings go to a payout wallet. That wallet can be
a [Safe](https://app.safe.protofire.io) multisig on Sei, so moving the money
on-chain out of the Safe needs your owners' approval. This guide shows how the
Safe logs in to Monaco, checks what it has earned, sets your builder fees, and
withdraws the earnings to itself.

Everything runs from one script. Each run logs the Safe in, which needs your
Safe's usual number of owner approvals.

## Before you start

1. **Send Monaco your Safe address** on Sei mainnet, with the name of your
   frontend. Monaco sets it as your payout wallet and lets it log in. Wait
   for Monaco to confirm before you run the script.
2. **Have your owners ready.** Each login asks the owners to confirm one
   message in the Safe app. Enough of them to meet your Safe's threshold must
   confirm within 30 minutes.
3. **Pick one owner to run the script.** The script needs that owner's private
   key. It uses it to propose each login message and to send the final
   withdrawal transaction, so the owner's address needs a little SEI for gas.
   Owners must be ordinary wallets, not other Safes.
4. **Install the tools:** Node.js 22 or later, then:

```bash theme={null}
npm install @0xmonaco/core viem tsx
```

## How the login works

A Safe has no private key, so it cannot sign Monaco's login message the way a
normal wallet does. Instead:

1. The script asks Monaco for a login message.
2. The owner running the script signs it and posts it to the Safe app, where
   it appears under **Messages**.
3. The other owners open the Safe in the Safe app, go to **Messages**, and
   confirm it.
4. Once enough owners have confirmed, the script sends the combined signatures
   to Monaco. Monaco asks your Safe contract whether they are valid (EIP-1271)
   and logs the Safe in.

<Warning>
  Approving a login gives that session full control of the Safe's Monaco
  balance until the session ends, including withdrawing it to any address.
  Owners confirm only the login, not each action after it. Confirm a login
  message only when you know who started it and expect it. This script
  withdraws only to the Safe and logs out when it finishes, which ends the
  session.
</Warning>

The session belongs to your Safe and your frontend. The config, fee and
payout-history calls cover only that frontend. The withdrawable balance belongs
to the Safe itself: if the Safe is the payout wallet for more than one
frontend, it holds their earnings together, and `WITHDRAW=1` withdraws all of
it. Use a separate Safe for each frontend to keep them apart.

## The script

Save this as `safe-payout.ts`:

```typescript theme={null}
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());
```

`MONACO_CLIENT_ID` is your frontend's client ID, the same one your app passes
to `sdk.login`. The SDK needs `seiRpcUrl` whenever you pass your own wallet
client, as this script does.

## Check what you have earned

Run the script with no options:

```bash theme={null}
SAFE_ADDRESS=0xYourSafe MONACO_CLIENT_ID=yourClientId OWNER_PRIVATE_KEY=0x... npx tsx safe-payout.ts
```

Confirm the message in the Safe app when asked. The script then prints:

* your current taker and maker builder fees;
* **Earned**: your frontend's lifetime totals per payout kind — `REVENUE_SHARE`,
  `BUILDER_FEE` (paid by your takers) and `BUILDER_MAKER_FEE` (paid by your
  makers). These count every payout wallet your frontend has used;
* **Available to withdraw**: what this Safe can withdraw right now.

Amounts are raw token units. USDC has 6 decimals, so `1500000` is 1.5 USDC.
See [BuilderCodes](/developers/buildercodes#raw-unit-fields) for more.

Run this first, before you withdraw, to check the Safe logs in and the numbers
look right.

## Set your builder fees

Add `TAKER_BPS`, and `MAKER_BPS` if you want to change the maker fee too
(leaving it out keeps the current maker fee):

```bash theme={null}
TAKER_BPS=5 MAKER_BPS=2 SAFE_ADDRESS=... MONACO_CLIENT_ID=... OWNER_PRIVATE_KEY=... npx tsx safe-payout.ts
```

Both are basis points (1 bps = 0.01%) charged on top of Monaco's fees: the taker
fee on your users' taker fills, and the maker fee on fills of their resting
orders. Each must be between 0 and the cap the script's config reports
(`builderFeeCapBps`, never above 100). You get two changes in any rolling 24
hours; setting the fees you already have does not use one. New fees reach
orders within a few seconds. The full rules are in
[BuilderCodes](/developers/buildercodes#frontend-endpoints).

## Withdraw to the Safe

Add `WITHDRAW=1`:

```bash theme={null}
WITHDRAW=1 SAFE_ADDRESS=... MONACO_CLIENT_ID=... OWNER_PRIVATE_KEY=... npx tsx safe-payout.ts
```

The script:

1. moves any earnings waiting in the Safe into its Monaco balance (no gas);
2. starts a withdrawal of the Safe's whole USDC balance on Monaco to the Safe's
   own address;
3. waits until the withdrawal is ready on-chain, usually a few minutes (it
   gives up after an hour and tells you how to finish later);
4. sends the withdrawal transaction from the owner's address, which pays the
   gas. The funds always go to the Safe, whoever sends it.

Keep the withdrawal number the script prints. If the script stops after
starting the withdrawal, the money has already left your Monaco balance and is
waiting for step 4. Do not run `WITHDRAW=1` again; finish that withdrawal
instead. This does not log in, so the owners do not need to confirm anything:

```bash theme={null}
WITHDRAWAL_INDEX=1234 SAFE_ADDRESS=... MONACO_CLIENT_ID=... OWNER_PRIVATE_KEY=... npx tsx safe-payout.ts
```

If moving the earnings into the Safe's balance fails with a `503`, do not
retry straight away: see
[unknown outcomes](/developers/buildercodes#unknown-outcomes-do-not-retry-blindly).

## Troubleshooting

| What you see | What to do |
| - | - |
| `403` on login: "User is not active. Please wait for approval." | Monaco has not finished setting up your Safe. Contact Monaco. |
| Login fails after all owners confirmed | Check `SAFE_ADDRESS` is the Safe Monaco set as your payout wallet, and that every owner who confirmed is an owner of that Safe. |
| `403` on the config or fee calls | `MONACO_CLIENT_ID` is not the frontend whose payout wallet is this Safe. |
| "The login challenge expired" | Not enough owners confirmed within 30 minutes. Run the script again; it posts a new message. |
| `409` with `builder_fee_change_limit` | You have used both fee changes for the last 24 hours. Try again after `nextChangeAllowedAt`. |
| "No USDC to withdraw" | The Safe has no earnings and no USDC on Monaco. |
| The withdrawal transaction reverts when finishing a withdrawal | That withdrawal was most likely already sent, by an earlier run or by someone else (the vault refuses it twice). Check the Safe's USDC balance on a Sei explorer. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.