← All articles

How fintechs make sure no single employee can move funds

Keep the signing key in an enclave, put the payment rules in code next to it, and require a multisig to change those rules. Here is how to set that up on Lit, including a rule that needs two approvers for large payments.

Lit Protocol

To make sure no single employee or vendor can move funds, keep the signing key somewhere no person holds it, and make every payment pass rules that one person cannot skip or change. On Lit Protocol, the key lives inside a hardware enclave, and Lit's code never exports it. The payment rules are a JavaScript function, called a Lit Action, that runs in the enclave before every signature. The record of which rules apply to which wallet lives in a contract on Base, and you can make your own Safe multisig the only account the contract accepts rule changes from.

I will show a payment rule that needs two approvers for large payments, and then the setup that keeps any one person from changing it.

The payment rule

This action pays USDC on Base, and only to approved vendors. Payments up to 10,000 USDC go through on their own. Anything larger needs signatures from two of three named approvers, over the exact payment and before it expires.

// payments-v1.js
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const VENDORS = ["0xVendorOne...", "0xVendorTwo..."].map(a => a.toLowerCase());
const APPROVERS = ["0xAliceApprover...", "0xBobApprover...", "0xCarolApprover..."].map(a => a.toLowerCase());
const AUTO_LIMIT = 10_000_000000n; // 10,000 USDC, which has 6 decimals

async function main({ pkpId, payment, approvals }) {
  const { paymentId, to, amount, expires } = payment;
  if (typeof amount !== "string" || !/^[0-9]+$/.test(amount)) return { sent: false, reason: "amount must be a whole number string" };
  if (!VENDORS.includes(String(to).toLowerCase())) return { sent: false, reason: "not an approved vendor" };
  if (!(Number(expires) > Date.now())) return { sent: false, reason: "payment request expired" };

  if (BigInt(amount) > AUTO_LIMIT) {
    const message = JSON.stringify({ pkpId: pkpId.toLowerCase(), paymentId, to: to.toLowerCase(), amount, expires });
    const recover = sig => { try { return ethers.utils.verifyMessage(message, sig).toLowerCase(); } catch { return null; } };
    const signers = new Set((approvals || []).map(recover));
    if (APPROVERS.filter(a => signers.has(a)).length < 2) return { sent: false, reason: "needs two approvers" };
  }

  const provider = new ethers.providers.StaticJsonRpcProvider("https://mainnet.base.org");
  const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId }), provider);
  const usdc = new ethers.Contract(USDC, ["function transfer(address,uint256) returns (bool)"], wallet);
  const tx = await usdc.transfer(to, amount);
  return { sent: true, paymentId, hash: tx.hash };
}

Each approver signs the same message with their own wallet. The message names the wallet, the payment ID, the recipient, the amount, and the expiry, so an approval cannot be moved to a different payment or a different wallet. expires is a string holding a time in milliseconds, and amount is a string in USDC's smallest unit.

const message = JSON.stringify({ pkpId: pkpAddress.toLowerCase(), paymentId: "p-2291", to: vendor.toLowerCase(), amount: "25000000000", expires: "1792512000000" });
const signature = await approverWallet.signMessage(message);

The vendors, the approvers, the threshold, and the limit are written into the code. An action you approve can still return the key if its code does that, so review this file like any code that moves money. Lit knows the action by a hash of its code, so changing any of them makes a different action that the wallet will not run until someone adds it.

The action does not keep a record of past payments, so it cannot tell when the same approved payment is sent twice before it expires. Keep expiries short. For a stronger check, have the action look up the payment ID in your own ledger before it signs. The post on writing signing policies as code shows how to check your ledger from inside an action.

Keeping any one person from changing the rules

The payment service gets a usage API key that can run this action and nothing else. It cannot add a new action, add a wallet, or change the rules. If someone steals it, they can send payments of up to 10,000 USDC each to your approved vendors until the wallet is empty, and they cannot do anything else. So keep only a working balance in the wallet, along with a little ETH on Base for gas.

The account itself is the part to lock down. A new Lit account starts in API mode, where Lit holds the account's owner credential and relays your changes. Set up the wallet, the group, and the usage key first, with the calls in the API reference. Then convert the account to ChainSecured mode with a Safe as the owner. In the Lit dashboard, open the account menu, click Convert to ChainSecured, and connect the Safe. The Safe must already be deployed on Base, and its signers should be ready to sign within five minutes. After that, adding a new version of the rule or adding a wallet is a Safe transaction that needs your threshold of signers. No single employee can change the rules, and neither can any single person at Lit.

What this setup gives you and its limits

With this setup, a payment over the limit needs two people, and a change to the rule needs your Safe's threshold. Every change is a public transaction on Base that your auditors can read. The post on proving key custody to auditors goes through how they can check it.

A Lit Safe that needs 2 of its 4 signers approves the code that runs in Lit's enclave, and the same Safe can upgrade the account contract that stores your rules. Either change would be a public transaction on Base. If you need those decisions to sit with your own signers, Lit is open source and you can run your own deployment.

Working with Lit

Lit works with fintech teams to set up payment rules and account ownership like this. To talk about your payment flows and approvals, use the Lit contact form. The account modes are explained at developer.litprotocol.com/management/account_modes.