feat: MPC-signed bridge payouts, replace raw private key signer

- Add mpc-signer.ts: signs EVM transactions via MPC API
- Update swaps.ts payout handler to use MPC when MPC_WALLET_ID is set
- Falls back to LUX_SIGNER private key when MPC is not configured
- MPC treasury wallets created: fee-collector + bridge-signer
- Env vars: MPC_API_URL, MPC_API_TOKEN, MPC_WALLET_ID, BRIDGE_FEE_COLLECTOR
This commit is contained in:
2026-03-06 00:35:35 -08:00
parent d54d690693
commit b7802b82fb
2 changed files with 218 additions and 31 deletions
+153
View File
@@ -0,0 +1,153 @@
import { JsonRpcProvider, Transaction, parseUnits, Interface, keccak256, getBytes } from "ethers"
import axios from "axios"
const MPC_API_URL = process.env.MPC_API_URL || "https://mpc.lux.network"
const MPC_API_TOKEN = process.env.MPC_API_TOKEN || ""
const MPC_WALLET_ID = process.env.MPC_WALLET_ID || ""
interface MPCSignResult {
r: string
s: string
signature?: string
}
const mpcHeaders = () => ({
Authorization: `Bearer ${MPC_API_TOKEN}`,
"Content-Type": "application/json",
})
/**
* Sign and broadcast an EVM contract call via MPC.
* 1. Build unsigned tx (to, data, gas, nonce, chainId)
* 2. Serialize the unsigned tx hash
* 3. POST to MPC API /transactions with raw_tx = unsigned tx hash
* 4. Get back r, s signature components
* 5. Attach signature to tx and broadcast via provider
*/
export async function mpcSignAndSend(
provider: JsonRpcProvider,
to: string,
data: string,
value: bigint = 0n,
): Promise<string> {
const feeData = await provider.getFeeData()
const network = await provider.getNetwork()
// Get nonce for the MPC wallet address
const walletInfo = await axios.get(
`${MPC_API_URL}/api/v1/wallets/${MPC_WALLET_ID}`,
{ headers: mpcHeaders() }
)
const fromAddress = walletInfo.data.ethAddress || walletInfo.data.eth_address
if (!fromAddress) {
throw new Error(`MPC wallet ${MPC_WALLET_ID} has no ETH address`)
}
const nonce = await provider.getTransactionCount(fromAddress)
// Build unsigned transaction
const tx = Transaction.from({
type: 2, // EIP-1559
to,
data,
value,
nonce,
chainId: network.chainId,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
gasLimit: 300000n,
})
// Get the unsigned tx hash to sign
const unsignedHash = tx.unsignedHash
const rawTxHex = Buffer.from(getBytes(unsignedHash)).toString("hex")
// Submit to MPC API for signing
const signResp = await axios.post(
`${MPC_API_URL}/api/v1/transactions`,
{
wallet_id: MPC_WALLET_ID,
tx_type: "bridge_payout",
chain: `eip155:${network.chainId}`,
to_address: to,
raw_tx: rawTxHex,
},
{ headers: mpcHeaders() }
)
const txRecord = signResp.data
// Poll for signature completion (MPC signing is async)
let signed = txRecord
for (let i = 0; i < 30; i++) {
if (signed.status === "signed" || signed.status === "confirmed") break
if (signed.status === "failed") throw new Error("MPC signing failed")
await new Promise((r) => setTimeout(r, 2000))
const poll = await axios.get(
`${MPC_API_URL}/api/v1/transactions/${signed.id}`,
{ headers: mpcHeaders() }
)
signed = poll.data
}
if (signed.status !== "signed" && signed.status !== "confirmed") {
throw new Error(`MPC signing timed out (status: ${signed.status})`)
}
// Reconstruct signature
const rHex = signed.signatureR || signed.signature_r
const sHex = signed.signatureS || signed.signature_s
if (!rHex || !sHex) {
throw new Error("MPC response missing r/s signature components")
}
// Attach signature to tx
const rBuf = Buffer.from(rHex, "hex")
const sBuf = Buffer.from(sHex, "hex")
// Determine v (recovery id) — try both 0 and 1
tx.signature = {
r: "0x" + rBuf.toString("hex"),
s: "0x" + sBuf.toString("hex"),
v: 0,
}
// Broadcast
const signedTxHex = tx.serialized
const broadcastResult = await provider.broadcastTransaction(signedTxHex)
await broadcastResult.wait()
return broadcastResult.hash
}
/**
* Sign and send a bridgeMint call via MPC.
*/
export async function mpcBridgeMint(
provider: JsonRpcProvider,
tokenAddress: string,
recipient: string,
amount: bigint,
abi: any[],
): Promise<string> {
const iface = new Interface(abi)
const data = iface.encodeFunctionData("bridgeMint", [recipient, amount])
return mpcSignAndSend(provider, tokenAddress, data)
}
/**
* Send native token (LUX/ZOO) via MPC signer.
*/
export async function mpcSendNative(
provider: JsonRpcProvider,
to: string,
value: bigint,
): Promise<string> {
return mpcSignAndSend(provider, to, "0x", value)
}
/**
* Check if MPC signing is configured and available.
*/
export function isMPCSigningEnabled(): boolean {
return !!(MPC_API_URL && MPC_API_TOKEN && MPC_WALLET_ID)
}
+65 -31
View File
@@ -9,6 +9,7 @@ import { isValidAddress } from "@/util"
import { getTokenPrice } from "./tokens"
import { isExitFromLux, BRIDGE_FEE_RATE } from "./quote"
import { createMPCWalletForDeposit, checkNativeDeposit, archiveMPCWallet, NETWORK_ASSET_MAP } from "./mpc-wallet"
import { isMPCSigningEnabled, mpcBridgeMint, mpcSendNative } from "./mpc-signer"
export interface SwapData {
amount: number
@@ -633,7 +634,6 @@ export async function handlerUtilaPayoutAction(swapId: string) {
})
if (confirmed) {
const privateKey = process.env.LUX_SIGNER
const feeCollector = process.env.BRIDGE_FEE_COLLECTOR || '0x0000000000000000000000000000000000000000'
// 1% fee only on exits from Lux/Zoo; entries are free
@@ -656,35 +656,61 @@ export async function handlerUtilaPayoutAction(swapId: string) {
token: swap.destination_asset.contract_address,
payoutAmount,
feeAmount,
feeCollector
feeCollector,
mpcEnabled: isMPCSigningEnabled()
})
if (!rpc) {
throw new Error(`No RPC found for chain ${swap?.destination_network?.internal_name}`)
}
const provider = new JsonRpcProvider(rpc)
const feeData = await provider.getFeeData()
// signer
const wallet = new Wallet(privateKey!, provider)
const decimals = swap?.destination_asset.decimals ?? 18
const tokenAddress = swap.destination_asset.contract_address as string
const mintAmount = parseUnits(payoutAmount.toFixed(decimals), decimals)
const feeAmountWei = feeAmount > 0 ? parseUnits(feeAmount.toFixed(decimals), decimals) : 0n
const useMPC = isMPCSigningEnabled()
let signerAddress: string
let mintTxHash: string
try {
console.log(`>> Minting PayoutToken for this Swap [${swap.id}]`)
console.log(`>> Minting PayoutToken for this Swap [${swap.id}] (MPC: ${useMPC})`)
const contract = new Contract(swap.destination_asset.contract_address as string, ERC20B_ABI, wallet)
const decimals = swap?.destination_asset.decimals ?? 18
if (useMPC) {
// MPC-signed payout
mintTxHash = await mpcBridgeMint(provider, tokenAddress, swap.destination_address, mintAmount, ERC20B_ABI)
signerAddress = process.env.MPC_WALLET_ID || 'mpc'
console.log(`>> Minted L/Z tokens via MPC ${mintTxHash}`)
// Mint net amount to user (after 1% fee)
const txMint = await contract.bridgeMint(swap.destination_address, parseUnits(payoutAmount.toFixed(decimals), decimals))
await txMint.wait()
console.log(`>> Minted L/Z tokens ${txMint.hash}`)
// Mint fee to fee collector via MPC
if (feeAmountWei > 0n && feeCollector !== '0x0000000000000000000000000000000000000000') {
try {
const feeTxHash = await mpcBridgeMint(provider, tokenAddress, feeCollector, feeAmountWei, ERC20B_ABI)
console.log(`>> Collected ${feeAmount} fee to ${feeCollector} via MPC (${feeTxHash})`)
} catch (feeErr) {
console.log(">> Fee collection failed (payout still succeeded):", feeErr)
}
}
} else {
// Legacy private key signer
const privateKey = process.env.LUX_SIGNER
const wallet = new Wallet(privateKey!, provider)
signerAddress = wallet.address
// Mint fee to fee collector
if (feeAmount > 0 && feeCollector !== '0x0000000000000000000000000000000000000000') {
try {
const txFee = await contract.bridgeMint(feeCollector, parseUnits(feeAmount.toFixed(decimals), decimals))
await txFee.wait()
console.log(`>> Collected ${feeAmount} fee to ${feeCollector} (${txFee.hash})`)
} catch (feeErr) {
console.log(">> Fee collection failed (payout still succeeded):", feeErr)
const contract = new Contract(tokenAddress, ERC20B_ABI, wallet)
const txMint = await contract.bridgeMint(swap.destination_address, mintAmount)
await txMint.wait()
mintTxHash = txMint.hash
console.log(`>> Minted L/Z tokens ${mintTxHash}`)
// Mint fee to fee collector
if (feeAmountWei > 0n && feeCollector !== '0x0000000000000000000000000000000000000000') {
try {
const txFee = await contract.bridgeMint(feeCollector, feeAmountWei)
await txFee.wait()
console.log(`>> Collected ${feeAmount} fee to ${feeCollector} (${txFee.hash})`)
} catch (feeErr) {
console.log(">> Fee collection failed (payout still succeeded):", feeErr)
}
}
}
@@ -692,10 +718,10 @@ export async function handlerUtilaPayoutAction(swapId: string) {
data: {
status: "payout",
type: TransactionType.Output,
from: wallet.address,
from: signerAddress,
to: swap.destination_address,
amount: payoutAmount,
transaction_hash: txMint.hash,
transaction_hash: mintTxHash,
confirmations: 2,
max_confirmations: 2,
swap: {
@@ -726,21 +752,29 @@ export async function handlerUtilaPayoutAction(swapId: string) {
throw new Error("Error while minting Z/L tokens")
}
// Refuel: send 1 native token if balance < 1
try {
const _balance = await provider.getBalance(swap.destination_address)
console.log(">> Lux or Zoo balance:", Number(formatEther(_balance)));
if (Number(formatEther(_balance)) < 1) {
console.log(">> Sending 1 L/Z token");
const luxSendTxData = {
to: swap.destination_address,
value: parseEther('1'), // eth to wei
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
gasLimit: 3000000,
if (useMPC) {
const refuelHash = await mpcSendNative(provider, swap.destination_address, parseEther('1'))
console.log(`>> 1 Zoo or Lux sent via MPC to ${swap.destination_address} (${refuelHash})`)
} else {
const privateKey = process.env.LUX_SIGNER
const wallet = new Wallet(privateKey!, provider)
const feeData = await provider.getFeeData()
const _txSendLux = await wallet.sendTransaction({
to: swap.destination_address,
value: parseEther('1'),
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
gasLimit: 3000000,
})
await _txSendLux.wait()
console.log(`>> 1 Zoo or Lux is sent to ${swap.destination_address}`)
}
const _txSendLux = await wallet.sendTransaction(luxSendTxData)
await _txSendLux.wait()
console.log(`>> 1 Zoo or Lux is sent to ${swap.destination_address}`)
}
} catch (err) {
console.log(">> Error while sending 1 Z/L tokens", err)