Skip to content

Commit b12c295

Browse files
committed
Implement cross-chain transfer functionality with approveAndCall method in MetaMultiSigWallet. Update TransactionService to handle cross-chain transactions and add ChainSelector component in TransferContainer for destination chain selection. Update relevant contracts and ABI for new functionality.
1 parent 3960701 commit b12c295

11 files changed

Lines changed: 478 additions & 66 deletions

File tree

packages/backend/src/relayer-wallet/relayer-wallet.service.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,43 @@ export class RelayerService {
263263
} catch (e) {
264264
// Not a batchTransferMulti call, continue
265265
}
266+
267+
// Try decode approveAndCall (cross-chain bridge via OFT Adapter)
268+
try {
269+
const decoded = decodeFunctionData({
270+
abi: [
271+
{
272+
name: 'approveAndCall',
273+
type: 'function',
274+
inputs: [
275+
{ name: 'token', type: 'address' },
276+
{ name: 'spender', type: 'address' },
277+
{ name: 'approveAmount', type: 'uint256' },
278+
{ name: 'callTarget', type: 'address' },
279+
{ name: 'callValue', type: 'uint256' },
280+
{ name: 'callData', type: 'bytes' },
281+
],
282+
},
283+
],
284+
data: data as `0x${string}`,
285+
});
286+
287+
if (decoded.functionName === 'approveAndCall') {
288+
const tokenAddress = (decoded.args[0] as string).toLowerCase();
289+
const approveAmount = decoded.args[2] as bigint;
290+
const callValue = decoded.args[4] as bigint;
291+
292+
erc20Requirements[tokenAddress] =
293+
(erc20Requirements[tokenAddress] || 0n) + approveAmount;
294+
requiredBalance = requiredBalance + callValue;
295+
296+
this.logger.log(
297+
`ApproveAndCall detected. Token: ${tokenAddress}, Amount: ${approveAmount}, LZ fee: ${callValue}`,
298+
);
299+
}
300+
} catch (e) {
301+
// Not an approveAndCall, continue
302+
}
266303
}
267304

268305
// Check ETH balance

packages/backend/src/transaction/transaction.service.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
encodeBatchTransferMulti,
1616
encodeERC20Transfer,
1717
encodeBatchTransfer,
18+
encodeBridgeETHTo,
19+
encodeLzSend,
20+
encodeApproveAndCall,
1821
TxStatus,
1922
TX_CREATED_EVENT,
2023
TX_VOTED_EVENT,
@@ -30,6 +33,11 @@ import {
3033
encodeRemoveSigners,
3134
SignerData,
3235
ZERO_ADDRESS,
36+
getTokenByAddress,
37+
getBridgeMechanism,
38+
getBridgeContract,
39+
OP_BRIDGE_ADDRESSES,
40+
LZ_ENDPOINT_IDS,
3341
} from '@polypay/shared';
3442
import { RelayerService } from '@/relayer-wallet/relayer-wallet.service';
3543
import { BatchItemService } from '@/batch-item/batch-item.service';
@@ -164,6 +172,8 @@ export class TransactionService {
164172
contactId: dto.contactId,
165173
signerData: dto.signers ? JSON.stringify(dto.signers) : null,
166174
newThreshold: dto.newThreshold,
175+
destChainId: dto.destChainId,
176+
bridgeFee: dto.bridgeFee,
167177
createdBy: userCommitment,
168178
status: 'PENDING',
169179
batchData,
@@ -654,7 +664,7 @@ export class TransactionService {
654664
}
655665

656666
// Build execute params based on tx type
657-
const { to, value, data } = this.buildExecuteParams(transaction);
667+
const { to, value, data } = await this.buildExecuteParams(transaction);
658668

659669
// Format proofs for smart contract
660670
const zkProofs = approveVotes.map((vote) => ({
@@ -1106,17 +1116,27 @@ export class TransactionService {
11061116
);
11071117
}
11081118

1109-
private buildExecuteParams(transaction: Transaction): {
1119+
private async buildExecuteParams(transaction: Transaction): Promise<{
11101120
to: string;
11111121
value: string;
11121122
data: string;
1113-
} {
1123+
}> {
11141124
switch (transaction.type) {
1115-
case TxType.TRANSFER:
1125+
case TxType.TRANSFER: {
1126+
// Check for cross-chain bridge
1127+
if (transaction.destChainId) {
1128+
const account = await this.prisma.account.findUnique({
1129+
where: { address: transaction.accountAddress },
1130+
});
1131+
if (account && transaction.destChainId !== account.chainId) {
1132+
return this.buildBridgeExecuteParams(transaction, account.chainId);
1133+
}
1134+
}
1135+
11161136
// ERC20 transfer
11171137
if (transaction?.tokenAddress) {
11181138
return {
1119-
to: transaction.tokenAddress, // Token contract address
1139+
to: transaction.tokenAddress,
11201140
value: '0',
11211141
data: encodeERC20Transfer(
11221142
transaction.to,
@@ -1130,6 +1150,7 @@ export class TransactionService {
11301150
value: transaction.value,
11311151
data: '0x',
11321152
};
1153+
}
11331154

11341155
case TxType.ADD_SIGNER: {
11351156
const signers: SignerData[] = transaction.signerData
@@ -1200,6 +1221,110 @@ export class TransactionService {
12001221
}
12011222
}
12021223

1224+
private buildBridgeExecuteParams(
1225+
transaction: Transaction,
1226+
srcChainId: number,
1227+
): { to: string; value: string; data: string } {
1228+
const destChainId = transaction.destChainId;
1229+
const tokenSymbol = transaction.tokenAddress
1230+
? transaction.tokenAddress === ZERO_ADDRESS
1231+
? 'ETH'
1232+
: null
1233+
: 'ETH';
1234+
1235+
let resolvedSymbol = tokenSymbol;
1236+
if (!resolvedSymbol && transaction.tokenAddress) {
1237+
resolvedSymbol = this.resolveTokenSymbol(
1238+
transaction.tokenAddress,
1239+
srcChainId,
1240+
);
1241+
}
1242+
1243+
const mechanism = getBridgeMechanism(
1244+
srcChainId,
1245+
destChainId,
1246+
resolvedSymbol,
1247+
);
1248+
1249+
if (!mechanism) {
1250+
throw new BadRequestException(
1251+
`No bridge route for ${resolvedSymbol} from chain ${srcChainId} to ${destChainId}`,
1252+
);
1253+
}
1254+
1255+
const recipient = transaction.to;
1256+
const amount = BigInt(transaction.value);
1257+
const bridgeFee = transaction.bridgeFee
1258+
? BigInt(transaction.bridgeFee)
1259+
: 0n;
1260+
1261+
if (mechanism === 'OP_STACK') {
1262+
const bridgeAddress = OP_BRIDGE_ADDRESSES[srcChainId];
1263+
if (!bridgeAddress) {
1264+
throw new BadRequestException(`No OP bridge on chain ${srcChainId}`);
1265+
}
1266+
return {
1267+
to: bridgeAddress,
1268+
value: amount.toString(),
1269+
data: encodeBridgeETHTo(recipient),
1270+
};
1271+
}
1272+
1273+
// LAYERZERO
1274+
const dstEid = LZ_ENDPOINT_IDS[destChainId];
1275+
if (!dstEid) {
1276+
throw new BadRequestException(`No LZ endpoint for chain ${destChainId}`);
1277+
}
1278+
1279+
const oftEntry = getBridgeContract(resolvedSymbol, srcChainId);
1280+
if (!oftEntry) {
1281+
throw new BadRequestException(
1282+
`No OFT contract for ${resolvedSymbol} on chain ${srcChainId}`,
1283+
);
1284+
}
1285+
1286+
const lzSendData = encodeLzSend(
1287+
dstEid,
1288+
recipient,
1289+
amount,
1290+
bridgeFee,
1291+
transaction.accountAddress,
1292+
);
1293+
1294+
if (oftEntry.type === 'adapter') {
1295+
// Base side: approve token + call adapter. Self-call with value=0.
1296+
return {
1297+
to: transaction.accountAddress,
1298+
value: '0',
1299+
data: encodeApproveAndCall(
1300+
transaction.tokenAddress,
1301+
oftEntry.address,
1302+
amount,
1303+
oftEntry.address,
1304+
bridgeFee,
1305+
lzSendData,
1306+
),
1307+
};
1308+
}
1309+
1310+
// Horizen side: direct OFT send with bridgeFee as value
1311+
return {
1312+
to: oftEntry.address,
1313+
value: bridgeFee.toString(),
1314+
data: lzSendData,
1315+
};
1316+
}
1317+
1318+
private resolveTokenSymbol(tokenAddress: string, chainId: number): string {
1319+
const token = getTokenByAddress(tokenAddress, chainId);
1320+
if (token.address === ZERO_ADDRESS) {
1321+
throw new BadRequestException(
1322+
`Cannot resolve token symbol for address ${tokenAddress} on chain ${chainId}`,
1323+
);
1324+
}
1325+
return token.symbol;
1326+
}
1327+
12031328
/**
12041329
* Check if transaction should be marked as FAILED
12051330
* Query totalSigners realtime from account.signers

packages/hardhat/contracts/MetaMultiSigWallet.sol

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,33 @@ contract MetaMultiSigWallet {
214214
}
215215
}
216216

217+
/**
218+
* @notice Approve an ERC20 token and call a target contract in one atomic operation.
219+
* Used for cross-chain bridge flows (e.g. OFT Adapter: approve + send).
220+
* @param token ERC20 token to approve
221+
* @param spender Address to approve spending
222+
* @param approveAmount Amount to approve
223+
* @param callTarget Contract to call after approval
224+
* @param callValue Native ETH to send with the call (e.g. LayerZero fee)
225+
* @param callData Encoded function call for the target
226+
*/
227+
function approveAndCall(
228+
address token,
229+
address spender,
230+
uint256 approveAmount,
231+
address callTarget,
232+
uint256 callValue,
233+
bytes calldata callData
234+
) public onlySelf {
235+
(bool approveSuccess, bytes memory approveResult) = token.call(
236+
abi.encodeWithSignature("approve(address,uint256)", spender, approveAmount)
237+
);
238+
require(approveSuccess && (approveResult.length == 0 || abi.decode(approveResult, (bool))), "Approve failed");
239+
240+
(bool callSuccess,) = callTarget.call{value: callValue}(callData);
241+
require(callSuccess, "Call failed");
242+
}
243+
217244
// ============ View Functions ============
218245
function getTransactionHash(
219246
uint256 _nonce,

packages/hardhat/deployments/horizenTestnet/MetaMultiSigWallet.json

Lines changed: 74 additions & 23 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)