mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user