Skip to content

Commit 9606db5

Browse files
committed
chore: add task to oft-adapter example
1 parent eacea32 commit 9606db5

8 files changed

Lines changed: 420 additions & 0 deletions

File tree

.changeset/tricky-spies-dream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@layerzerolabs/oft-adapter-example": minor
3+
---
4+
5+
Adds send task to oft-adapter

examples/oft-adapter/hardhat.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { HardhatUserConfig, HttpNetworkAccountsUserConfig } from 'hardhat/types'
1414
import { EndpointId } from '@layerzerolabs/lz-definitions'
1515

1616
import './type-extensions'
17+
import './tasks/sendOFT'
1718

1819
// Set your preferred authentication method
1920
//

examples/oft-adapter/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
},
2323
"devDependencies": {
2424
"@babel/core": "^7.23.9",
25+
"@layerzerolabs/devtools-evm-hardhat": "^3.1.0",
2526
"@layerzerolabs/eslint-config-next": "~2.3.39",
27+
"@layerzerolabs/io-devtools": "~0.2.0",
2628
"@layerzerolabs/lz-definitions": "^3.0.75",
2729
"@layerzerolabs/lz-evm-messagelib-v2": "^3.0.75",
2830
"@layerzerolabs/lz-evm-protocol-v2": "^3.0.75",
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import path from 'path'
2+
3+
import { BigNumber, ContractTransaction } from 'ethers'
4+
import { parseUnits } from 'ethers/lib/utils'
5+
import { HardhatRuntimeEnvironment } from 'hardhat/types'
6+
7+
import { OmniPointHardhat, createGetHreByEid } from '@layerzerolabs/devtools-evm-hardhat'
8+
import { createLogger } from '@layerzerolabs/io-devtools'
9+
import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions'
10+
import { Options, addressToBytes32 } from '@layerzerolabs/lz-v2-utilities'
11+
12+
import { SendResult } from './types'
13+
import { DebugLogger, KnownErrors, getLayerZeroScanLink } from './utils'
14+
15+
const logger = createLogger()
16+
17+
export interface EvmArgs {
18+
srcEid: number
19+
dstEid: number
20+
amount: string
21+
to: string
22+
oappConfig: string
23+
minAmount?: string
24+
extraLzReceiveOptions?: string[]
25+
extraLzComposeOptions?: string[]
26+
extraNativeDropOptions?: string[]
27+
composeMsg?: string
28+
oftAddress?: string
29+
}
30+
31+
export async function sendEvm(
32+
{
33+
srcEid,
34+
dstEid,
35+
amount,
36+
to,
37+
oappConfig,
38+
minAmount,
39+
extraLzReceiveOptions,
40+
extraLzComposeOptions,
41+
extraNativeDropOptions,
42+
composeMsg,
43+
oftAddress,
44+
}: EvmArgs,
45+
hre: HardhatRuntimeEnvironment
46+
): Promise<SendResult> {
47+
if (endpointIdToChainType(srcEid) !== ChainType.EVM) {
48+
throw new Error(`non-EVM srcEid (${srcEid}) not supported here`)
49+
}
50+
51+
const getHreByEid = createGetHreByEid(hre)
52+
let srcEidHre: HardhatRuntimeEnvironment
53+
try {
54+
srcEidHre = await getHreByEid(srcEid)
55+
} catch (error) {
56+
DebugLogger.printErrorAndFixSuggestion(
57+
KnownErrors.ERROR_GETTING_HRE,
58+
`For network: ${endpointIdToNetwork(srcEid)}, OFT: ${oftAddress}`
59+
)
60+
throw error
61+
}
62+
const signer = (await srcEidHre.ethers.getSigners())[0]
63+
64+
// 1️⃣ resolve the OFT wrapper address
65+
let wrapperAddress: string
66+
if (oftAddress) {
67+
wrapperAddress = oftAddress
68+
} else {
69+
const layerZeroConfig = (await import(path.resolve('./', oappConfig))).default
70+
const { contracts } = typeof layerZeroConfig === 'function' ? await layerZeroConfig() : layerZeroConfig
71+
const wrapper = contracts.find((c: { contract: OmniPointHardhat }) => c.contract.eid === srcEid)
72+
if (!wrapper) throw new Error(`No config for EID ${srcEid}`)
73+
wrapperAddress = wrapper.contract.contractName
74+
? (await srcEidHre.deployments.get(wrapper.contract.contractName)).address
75+
: wrapper.contract.address || ''
76+
}
77+
78+
// 2️⃣ load IOFT ABI, extend it with token()
79+
const ioftArtifact = await srcEidHre.artifacts.readArtifact('IOFT')
80+
81+
// now attach
82+
const oft = await srcEidHre.ethers.getContractAt(ioftArtifact.abi, wrapperAddress, signer)
83+
84+
// 3️⃣ fetch the underlying ERC-20
85+
const underlying = await oft.token()
86+
87+
// 4️⃣ fetch decimals from the underlying token
88+
const erc20 = await srcEidHre.ethers.getContractAt('ERC20', underlying, signer)
89+
const decimals: number = await erc20.decimals()
90+
91+
// 5️⃣ normalize the user-supplied amount
92+
const amountUnits: BigNumber = parseUnits(amount, decimals)
93+
94+
// 6️⃣ Check if approval is required (for OFT Adapters) and handle approval
95+
try {
96+
const approvalRequired = await oft.approvalRequired()
97+
if (approvalRequired) {
98+
logger.info('OFT Adapter detected - checking ERC20 allowance...')
99+
100+
// Check current allowance
101+
const currentAllowance = await erc20.allowance(signer.address, wrapperAddress)
102+
logger.info(`Current allowance: ${currentAllowance.toString()}`)
103+
logger.info(`Required amount: ${amountUnits.toString()}`)
104+
105+
if (currentAllowance.lt(amountUnits)) {
106+
logger.info('Insufficient allowance - approving ERC20 tokens...')
107+
const approveTx = await erc20.approve(wrapperAddress, amountUnits)
108+
logger.info(`Approval transaction hash: ${approveTx.hash}`)
109+
await approveTx.wait()
110+
logger.info('ERC20 approval confirmed')
111+
} else {
112+
logger.info('Sufficient allowance already exists')
113+
}
114+
}
115+
} catch (error) {
116+
// If approvalRequired() doesn't exist or fails, assume it's a regular OFT (not an adapter)
117+
logger.info('No approval required (regular OFT detected)')
118+
}
119+
120+
// 7️⃣ hex string → Uint8Array → zero-pad to 32 bytes
121+
const toBytes = addressToBytes32(to)
122+
123+
// 8️⃣ Build options dynamically using Options.newOptions()
124+
let options = Options.newOptions()
125+
126+
// Add lzReceive options
127+
if (extraLzReceiveOptions && extraLzReceiveOptions.length > 0) {
128+
// Handle case where Hardhat's CSV parsing splits "gas,value" into separate elements
129+
if (extraLzReceiveOptions.length % 2 !== 0) {
130+
throw new Error(
131+
`Invalid lzReceive options: received ${extraLzReceiveOptions.length} values, but expected pairs of gas,value`
132+
)
133+
}
134+
135+
for (let i = 0; i < extraLzReceiveOptions.length; i += 2) {
136+
const gas = Number(extraLzReceiveOptions[i])
137+
const value = Number(extraLzReceiveOptions[i + 1]) || 0
138+
options = options.addExecutorLzReceiveOption(gas, value)
139+
logger.info(`Added lzReceive option: ${gas} gas, ${value} value`)
140+
}
141+
}
142+
143+
// Add lzCompose options
144+
if (extraLzComposeOptions && extraLzComposeOptions.length > 0) {
145+
// Handle case where Hardhat's CSV parsing splits "index,gas,value" into separate elements
146+
if (extraLzComposeOptions.length % 3 !== 0) {
147+
throw new Error(
148+
`Invalid lzCompose options: received ${extraLzComposeOptions.length} values, but expected triplets of index,gas,value`
149+
)
150+
}
151+
152+
for (let i = 0; i < extraLzComposeOptions.length; i += 3) {
153+
const index = Number(extraLzComposeOptions[i])
154+
const gas = Number(extraLzComposeOptions[i + 1])
155+
const value = Number(extraLzComposeOptions[i + 2]) || 0
156+
options = options.addExecutorComposeOption(index, gas, value)
157+
logger.info(`Added lzCompose option: index ${index}, ${gas} gas, ${value} value`)
158+
}
159+
}
160+
161+
// Add native drop options
162+
if (extraNativeDropOptions && extraNativeDropOptions.length > 0) {
163+
// Handle case where Hardhat's CSV parsing splits "amount,recipient" into separate elements
164+
if (extraNativeDropOptions.length % 2 !== 0) {
165+
throw new Error(
166+
`Invalid native drop options: received ${extraNativeDropOptions.length} values, but expected pairs of amount,recipient`
167+
)
168+
}
169+
170+
for (let i = 0; i < extraNativeDropOptions.length; i += 2) {
171+
const amountStr = extraNativeDropOptions[i]
172+
const recipient = extraNativeDropOptions[i + 1]
173+
174+
if (!amountStr || !recipient) {
175+
throw new Error(
176+
`Invalid native drop option: Both amount and recipient must be provided. Got amount="${amountStr}", recipient="${recipient}"`
177+
)
178+
}
179+
180+
try {
181+
options = options.addExecutorNativeDropOption(amountStr.trim(), recipient.trim())
182+
logger.info(`Added native drop option: ${amountStr.trim()} wei to ${recipient.trim()}`)
183+
} catch (error) {
184+
// Provide helpful context if the amount exceeds protocol limits
185+
const maxUint128 = BigInt('340282366920938463463374607431768211455') // 2^128 - 1
186+
const maxUint128Ether = Number(maxUint128) / 1e18 // Convert to ETH for readability
187+
188+
throw new Error(
189+
`Failed to add native drop option with amount ${amountStr.trim()} wei. ` +
190+
`LayerZero protocol constrains native drop amounts to uint128 maximum ` +
191+
`(${maxUint128.toString()} wei ≈ ${maxUint128Ether.toFixed(2)} ETH). ` +
192+
`Original error: ${error instanceof Error ? error.message : String(error)}`
193+
)
194+
}
195+
}
196+
}
197+
198+
const extraOptions = options.toHex()
199+
200+
// 9️⃣ build sendParam and dispatch
201+
const sendParam = {
202+
dstEid,
203+
to: toBytes,
204+
amountLD: amountUnits.toString(),
205+
minAmountLD: minAmount ? parseUnits(minAmount, decimals).toString() : amountUnits.toString(),
206+
extraOptions: extraOptions,
207+
composeMsg: composeMsg ? composeMsg.toString() : '0x',
208+
oftCmd: '0x',
209+
}
210+
211+
// 10️⃣ Quote (MessagingFee = { nativeFee, lzTokenFee })
212+
logger.info('Quoting the native gas cost for the send transaction...')
213+
let msgFee: { nativeFee: BigNumber; lzTokenFee: BigNumber }
214+
try {
215+
msgFee = await oft.quoteSend(sendParam, false)
216+
} catch (error) {
217+
DebugLogger.printErrorAndFixSuggestion(
218+
KnownErrors.ERROR_QUOTING_NATIVE_GAS_COST,
219+
`For network: ${endpointIdToNetwork(srcEid)}, OFT: ${oftAddress}`
220+
)
221+
throw error
222+
}
223+
logger.info('Sending the transaction...')
224+
let tx: ContractTransaction
225+
try {
226+
tx = await oft.send(sendParam, msgFee, signer.address, {
227+
value: msgFee.nativeFee,
228+
})
229+
} catch (error) {
230+
DebugLogger.printErrorAndFixSuggestion(
231+
KnownErrors.ERROR_SENDING_TRANSACTION,
232+
`For network: ${endpointIdToNetwork(srcEid)}, OFT: ${oftAddress}`
233+
)
234+
throw error
235+
}
236+
const receipt = await tx.wait()
237+
238+
const txHash = receipt.transactionHash
239+
const scanLink = getLayerZeroScanLink(txHash, srcEid >= 40_000 && srcEid < 50_000)
240+
241+
return { txHash, scanLink }
242+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { task, types } from 'hardhat/config'
2+
import { HardhatRuntimeEnvironment } from 'hardhat/types'
3+
4+
import { types as devtoolsTypes } from '@layerzerolabs/devtools-evm-hardhat'
5+
import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions'
6+
7+
import { EvmArgs, sendEvm } from './sendEvm'
8+
import { SendResult } from './types'
9+
import { DebugLogger, KnownOutputs, KnownWarnings, getBlockExplorerLink } from './utils'
10+
11+
interface MasterArgs {
12+
srcEid: number
13+
dstEid: number
14+
amount: string
15+
to: string
16+
/** Path to LayerZero config file (default: layerzero.config.ts) */
17+
oappConfig: string
18+
/** Minimum amount to receive in case of custom slippage or fees (human readable units, e.g. "1.5") */
19+
minAmount?: string
20+
/** Array of lzReceive options as comma-separated values "gas,value" - e.g. --extra-lz-receive-options "200000,0" */
21+
extraLzReceiveOptions?: string[]
22+
/** Array of lzCompose options as comma-separated values "index,gas,value" - e.g. --extra-lz-compose-options "0,500000,0" */
23+
extraLzComposeOptions?: string[]
24+
/** Array of native drop options as comma-separated values "amount,recipient" - e.g. --extra-native-drop-options "1000000000000000000,0x1234..." */
25+
extraNativeDropOptions?: string[]
26+
/** Arbitrary bytes message to deliver alongside the OFT */
27+
composeMsg?: string
28+
/** EVM: 20-byte hex address */
29+
oftAddress?: string
30+
}
31+
32+
task('lz:oft:send', 'Sends OFT tokens cross‐chain from EVM chains')
33+
.addParam('srcEid', 'Source endpoint ID', undefined, types.int)
34+
.addParam('dstEid', 'Destination endpoint ID', undefined, types.int)
35+
.addParam('amount', 'Amount to send (human readable units, e.g. "1.5")', undefined, types.string)
36+
.addParam('to', 'Recipient address (20-byte hex for EVM)', undefined, types.string)
37+
.addOptionalParam('oappConfig', 'Path to LayerZero config file', 'layerzero.config.ts', types.string)
38+
.addOptionalParam(
39+
'minAmount',
40+
'Minimum amount to receive in case of custom slippage or fees (human readable units, e.g. "1.5")',
41+
undefined,
42+
types.string
43+
)
44+
.addOptionalParam(
45+
'extraLzReceiveOptions',
46+
'Array of lzReceive options as comma-separated values "gas,value"',
47+
undefined,
48+
devtoolsTypes.csv
49+
)
50+
.addOptionalParam(
51+
'extraLzComposeOptions',
52+
'Array of lzCompose options as comma-separated values "index,gas,value"',
53+
undefined,
54+
devtoolsTypes.csv
55+
)
56+
.addOptionalParam(
57+
'extraNativeDropOptions',
58+
'Array of native drop options as comma-separated values "amount,recipient"',
59+
undefined,
60+
devtoolsTypes.csv
61+
)
62+
.addOptionalParam('composeMsg', 'Arbitrary bytes message to deliver alongside the OFT', undefined, types.string)
63+
.addOptionalParam(
64+
'oftAddress',
65+
'Override the source local deployment OFT address (20-byte hex for EVM)',
66+
undefined,
67+
types.string
68+
)
69+
.setAction(async (args: MasterArgs, hre: HardhatRuntimeEnvironment) => {
70+
const chainType = endpointIdToChainType(args.srcEid)
71+
let result: SendResult
72+
73+
if (args.oftAddress) {
74+
DebugLogger.printWarning(
75+
KnownWarnings.USING_OVERRIDE_OFT,
76+
`For network: ${endpointIdToNetwork(args.srcEid)}, OFT: ${args.oftAddress}`
77+
)
78+
}
79+
80+
// Only support EVM chains in this example
81+
if (chainType === ChainType.EVM) {
82+
result = await sendEvm(args as EvmArgs, hre)
83+
} else {
84+
throw new Error(
85+
`The chain type ${chainType} is not supported in this OFT example. Only EVM chains are supported.`
86+
)
87+
}
88+
89+
DebugLogger.printLayerZeroOutput(
90+
KnownOutputs.SENT_VIA_OFT,
91+
`Successfully sent ${args.amount} tokens from ${endpointIdToNetwork(args.srcEid)} to ${endpointIdToNetwork(args.dstEid)}`
92+
)
93+
94+
// print the explorer link for the srcEid from metadata
95+
const explorerLink = await getBlockExplorerLink(args.srcEid, result.txHash)
96+
// if explorer link is available, print the tx hash link
97+
if (explorerLink) {
98+
DebugLogger.printLayerZeroOutput(
99+
KnownOutputs.TX_HASH,
100+
`Explorer link for source chain ${endpointIdToNetwork(args.srcEid)}: ${explorerLink}`
101+
)
102+
}
103+
104+
// print the LayerZero Scan link from metadata
105+
DebugLogger.printLayerZeroOutput(
106+
KnownOutputs.EXPLORER_LINK,
107+
`LayerZero Scan link for tracking all cross-chain transaction details: ${result.scanLink}`
108+
)
109+
})
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface SendResult {
2+
txHash: string // EVM: receipt.transactionHash
3+
scanLink: string // LayerZeroScan link for cross-chain tracking
4+
}

0 commit comments

Comments
 (0)