Skip to content

Commit 2480202

Browse files
authored
feat(examples/lzapp-migration): unified send task + align DevEx with oft-solana (LayerZero-Labs#1527)
1 parent 1e08e11 commit 2480202

21 files changed

Lines changed: 599 additions & 434 deletions

examples/lzapp-migration/.env.example

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
# / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \ \ / / \
99
# `-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-' `-`-'
1010

11+
# EVM Variables
1112
# By default, the examples support both mnemonic-based and private key-based authentication
12-
#
1313
# You don't need to set both of these values, just pick the one that you prefer and set that one
1414
MNEMONIC=
15-
PRIVATE_KEY=
15+
# Private key for EVM contract owner/delegate
16+
PRIVATE_KEY=
17+
18+
# Solana Variables
19+
SOLANA_PRIVATE_KEY=
20+
SOLANA_KEYPAIR_PATH=
21+
# By default, the Solana example will use the default cluster RPC URL if no other value is provided
22+
RPC_URL_SOLANA=
23+
RPC_URL_SOLANA_TESTNET=

examples/lzapp-migration/.eslintrc.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1+
require('@rushstack/eslint-patch/modern-module-resolution');
2+
13
module.exports = {
24
root: true,
35
extends: ['@layerzerolabs/eslint-config-next/recommended'],
4-
settings: {
5-
'import/resolver': {
6-
typescript: {
7-
project: './tsconfig.json',
8-
},
9-
},
10-
},
116
rules: {
7+
// @layerzerolabs/eslint-config-next defines rules for turborepo-based projects
8+
// that are not relevant for this particular project
129
'turbo/no-undeclared-env-vars': 'off',
1310
'import/no-unresolved': 'warn',
1411
},

examples/lzapp-migration/README.md

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,14 +249,6 @@ pnpm hardhat lz:oft:solana:create --eid 40168 --program-id <PROGRAM_ID>
249249

250250
:information_source: You can also specify `--amount <AMOUNT>` to have the OFT minted to your deployer address upon token creation.
251251

252-
#### For OFTAdapter:
253-
254-
```bash
255-
pnpm hardhat lz:oft-adapter:solana:create --eid 40168 --program-id <PROGRAM_ID> --mint <TOKEN_MINT> --token-program <TOKEN_PROGRAM_ID>
256-
```
257-
258-
:information_source: You can use OFT Adapter if you want to use an existing token on Solana. For OFT Adapter, tokens will be locked when sending to other chains and unlocked when receiving from other chains.
259-
260252
#### For OFT Mint-And-Burn Adapter (MABA):
261253

262254
```bash
@@ -306,16 +298,41 @@ npx hardhat --network sepolia-testnet lz:lzapp:set-min-dst-gas --dst-eid 40168
306298

307299
### Calling Send
308300

301+
:information_source: Note that for sends, the amount is expected to be in human-readable form (conversion to the raw units via decimals is done by the script)
302+
309303
Sepolia V1 to Solana
310304

311305
```bash
312-
npx hardhat --network sepolia-testnet lz:oft-v1:send --dst-eid 40168 --amount 1000000000000000000 --to <SOLANA_ADDRESS>
306+
npx hardhat --network sepolia-testnet lz:oft:send --src-eid 10161 --dst-eid 40168 --amount 1 --to <SOLANA_ADDRESS>
313307
```
314308

315309
Solana to Sepolia V1
316310

317311
```bash
318-
npx hardhat lz:oft:solana:send --amount 1000000000 --from-eid 40168 --to <EVM_ADDRESS> --to-eid 10161
312+
npx hardhat lz:oft:send --amount 1 --src-eid 40168 --to <EVM_ADDRESS> --dst-eid 10161
313+
```
314+
315+
For more information on the unified send task across EVM and Solana, run:
316+
317+
```bash
318+
npx hardhat lz:oft:send --help
319+
```
320+
321+
### Set Message Execution Options
322+
323+
For custom gas settings, enable `enforcedOptions` in `layerzero.config.ts` or pass an `_options` value when calling `send()`.
324+
325+
When manually specifying options:
326+
327+
- **EVM → Solana:** set `sendParam.extraOptions` in [tasks/evm/sendOFT.ts](./tasks/evm/sendOFT.ts)
328+
- **Solana → EVM:** use the `options` param in [tasks/solana/sendOFT.ts](./tasks/solana/sendOFT.ts)
329+
330+
### Set a new Mint Authority
331+
332+
If you do not want the deployer to remain mint authority, create and set a new authority:
333+
334+
```bash
335+
pnpm hardhat lz:oft:solana:setauthority --eid <SOLANA_EID> --mint <TOKEN_MINT> --program-id <PROGRAM_ID> --escrow <ESCROW>
319336
```
320337

321338
Congratulations!

examples/lzapp-migration/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"lint:sol": "solhint 'contracts/**/*.sol'",
1616
"test": "$npm_execpath run test:hardhat",
1717
"test:anchor": "anchor test",
18+
"test:forge": "forge test",
1819
"test:hardhat": "hardhat test"
1920
},
2021
"resolutions": {
@@ -50,6 +51,7 @@
5051
"@babel/core": "^7.23.9",
5152
"@coral-xyz/anchor": "^0.29.0",
5253
"@layerzerolabs/eslint-config-next": "~2.3.39",
54+
"@layerzerolabs/io-devtools": "~0.2.0",
5355
"@layerzerolabs/lz-definitions": "^3.0.75",
5456
"@layerzerolabs/lz-evm-messagelib-v2": "^3.0.75",
5557
"@layerzerolabs/lz-evm-protocol-v2": "^3.0.75",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { task, types } from 'hardhat/config'
2+
import { HardhatRuntimeEnvironment } from 'hardhat/types'
3+
4+
import { ChainType, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions'
5+
6+
import { EvmArgs, sendEvm } from '../evm/v1/sendEvm'
7+
import { SolanaArgs, sendSolana } from '../solana/sendSolana'
8+
9+
import { SendResult } from './types'
10+
import { DebugLogger } from './utils'
11+
12+
interface MasterArgs {
13+
srcEid: number
14+
dstEid: number
15+
amount: string
16+
to: string
17+
oftAddress?: string
18+
oftProgramId?: string
19+
tokenProgram?: string
20+
}
21+
22+
task('lz:oft:send', 'Send OFT tokens between chains')
23+
.addParam('srcEid', 'Source endpoint ID', undefined, types.int)
24+
.addParam('dstEid', 'Destination endpoint ID', undefined, types.int)
25+
.addParam('amount', 'Amount to send (human units)', undefined, types.string)
26+
.addParam('to', 'Recipient address', undefined, types.string)
27+
.addOptionalParam('oftAddress', 'Override the local OFT address', undefined, types.string)
28+
.addOptionalParam('oftProgramId', 'Solana only: override the OFT program ID', undefined, types.string)
29+
.addOptionalParam('tokenProgram', 'Solana token program', undefined, types.string)
30+
.setAction(async (args: MasterArgs, hre: HardhatRuntimeEnvironment) => {
31+
const chainType = endpointIdToChainType(args.srcEid)
32+
let result: SendResult
33+
if (chainType === ChainType.EVM) {
34+
result = await sendEvm(args as EvmArgs, hre)
35+
} else if (chainType === ChainType.SOLANA) {
36+
result = await sendSolana(args as SolanaArgs)
37+
} else {
38+
throw new Error(`Unsupported chain type: ${chainType}`)
39+
}
40+
41+
DebugLogger.keyValue('src', endpointIdToNetwork(args.srcEid).network)
42+
DebugLogger.keyValue('tx', result.txHash)
43+
if (result.scanLink) DebugLogger.keyValue('scan', result.scanLink)
44+
})

examples/lzapp-migration/tasks/common/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ export const publicKey: CLIArgumentType<PublicKey> = {
1717
},
1818
validate() {},
1919
}
20+
21+
export interface SendResult {
22+
txHash: string
23+
scanLink: string
24+
}

examples/lzapp-migration/tasks/common/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,5 @@ export const createSdkFactory = (
4545
}
4646

4747
export { createSolanaSignerFactory }
48+
49+
export { DebugLogger, KnownErrors, KnownOutputs, KnownWarnings } from '@layerzerolabs/io-devtools'

examples/lzapp-migration/tasks/common/wire.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ task(TASK_LZ_OAPP_WIRE)
104104
//
105105
//
106106

107-
// construct the user's keypair via the SOLANA_PRIVATE_KEY env var
108-
const keypair = useWeb3Js().web3JsKeypair
107+
// construct the user's keypair via SOLANA_PRIVATE_KEY or other supported methods
108+
const keypair = (await useWeb3Js()).web3JsKeypair
109109
const userAccount = keypair.publicKey
110110

111111
const solanaDeployment = getSolanaDeployment(args.solanaEid)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import assert from 'assert'
2+
3+
import { bs58 } from '@coral-xyz/anchor/dist/cjs/utils/bytes'
4+
import { BigNumber } from 'ethers'
5+
import { BytesLike, parseUnits } from 'ethers/lib/utils'
6+
import { HardhatRuntimeEnvironment } from 'hardhat/types'
7+
8+
import { makeBytes32 } from '@layerzerolabs/devtools'
9+
import { createGetHreByEid } from '@layerzerolabs/devtools-evm-hardhat'
10+
import { ChainType, EndpointId, endpointIdToChainType, endpointIdToNetwork } from '@layerzerolabs/lz-definitions'
11+
import { Options } from '@layerzerolabs/lz-v2-utilities'
12+
13+
import layerzeroConfig from '../../../layerzero.config'
14+
import { SendResult } from '../../common/types'
15+
import { DebugLogger, KnownErrors } from '../../common/utils'
16+
import { getLayerZeroScanLink } from '../../solana'
17+
18+
export interface EvmArgs {
19+
srcEid: number
20+
dstEid: number
21+
amount: string
22+
to: string
23+
oftAddress?: string
24+
}
25+
26+
const PT_SEND = 0
27+
const GAS_LIMIT_SOLANA = 200_000 // Gas limit for the executor when sending to Solana
28+
const MSG_VALUE_SOLANA = 2_500_000 // For why this is necessary, see: https://docs.layerzero.network/v2/developers/solana/oft/account#setting-enforced-options-inbound-to-solana
29+
30+
const GAS_LIMIT_DEFAULT = 80_000 // Gas limit for the executor when sending to EVM / Aptos
31+
const MSG_VALUE_DEFAULT = 0 // No msg.value needed for EVM / Aptos
32+
33+
export async function sendEvm(
34+
{ srcEid, dstEid, amount, to, oftAddress }: EvmArgs,
35+
hre: HardhatRuntimeEnvironment
36+
): Promise<SendResult> {
37+
if (endpointIdToChainType(srcEid) !== ChainType.EVM) {
38+
throw new Error(`non-EVM srcEid (${srcEid}) not supported here`)
39+
}
40+
41+
const getHreByEid = createGetHreByEid(hre)
42+
let srcEidHre: HardhatRuntimeEnvironment
43+
try {
44+
srcEidHre = await getHreByEid(srcEid)
45+
} catch (error) {
46+
DebugLogger.printErrorAndFixSuggestion(
47+
KnownErrors.ERROR_GETTING_HRE,
48+
`For network: ${endpointIdToNetwork(srcEid)}, OFT: ${oftAddress}`
49+
)
50+
throw error
51+
}
52+
53+
const { deployer } = await srcEidHre.getNamedAccounts()
54+
const signer = await srcEidHre.ethers.getSigner(deployer) // getNamedSigner is not available in v1, so we use getSigner with deployer account
55+
56+
// 1️⃣ resolve the OFT wrapper address
57+
let wrapperAddress: string
58+
if (oftAddress) {
59+
wrapperAddress = oftAddress
60+
} else {
61+
const { contracts } = layerzeroConfig // not using simple config generator, so no need to check if type is function
62+
const wrapper = contracts.find((c) => c.contract.eid === srcEid)
63+
if (!wrapper) throw new Error(`No config for EID ${srcEid}`)
64+
wrapperAddress = wrapper.contract.contractName
65+
? (await srcEidHre.deployments.get(wrapper.contract.contractName)).address
66+
: wrapper.contract.address!
67+
}
68+
69+
// 2️⃣ load MyEndpointV1OFTV2Mock ABI, extend it with token()
70+
const ioftArtifact = await srcEidHre.artifacts.readArtifact('MyEndpointV1OFTV2Mock')
71+
72+
// now attach
73+
const oft = await srcEidHre.ethers.getContractAt(ioftArtifact.abi, wrapperAddress, signer)
74+
75+
// 3️⃣ fetch the underlying ERC-20
76+
const underlying = await oft.token()
77+
78+
// 4️⃣ fetch decimals from the underlying token
79+
const erc20 = await srcEidHre.ethers.getContractAt('ERC20', underlying, signer)
80+
const decimals: number = await erc20.decimals()
81+
82+
// 5️⃣ normalize the user-supplied amount
83+
const amountUnits: BigNumber = parseUnits(amount, decimals)
84+
85+
const minDstGas: BigNumber = await oft.minDstGasLookup(dstEid, PT_SEND) // 0 = send, 1 = send_and_call
86+
87+
assert(
88+
minDstGas.gt(0),
89+
"minDstGas must be a non-0 value to bypass gas assertion part of EndpointV1. Ensure you have called 'npx hardhat lz:epv1:set-min-dst-gas' for the destination eid"
90+
)
91+
92+
// Decide how to configure chain-specific values and encode `to`
93+
const dstChain = endpointIdToChainType(dstEid)
94+
let toBytes: string
95+
let MSG_VALUE: number
96+
let GAS_LIMIT: number
97+
98+
if (dstChain === ChainType.SOLANA) {
99+
// 1️⃣ Validate & encode Base58 → 32-byte buffer
100+
try {
101+
toBytes = makeBytes32(bs58.decode(to))
102+
} catch {
103+
throw new Error(`Invalid Solana address: not valid Base58`)
104+
}
105+
// 2️⃣ Solana-specific fee settings
106+
MSG_VALUE = MSG_VALUE_SOLANA
107+
GAS_LIMIT = GAS_LIMIT_SOLANA
108+
} else {
109+
// 1️⃣ Validate & encode hex (EVM, Move, Hyperliquid, etc.) → 32-byte buffer
110+
if (!/^0x[0-9a-fA-F]{40}$/.test(to)) {
111+
throw new Error(`Invalid address: expected 0x-prefixed 40 hex chars`)
112+
}
113+
toBytes = makeBytes32(to)
114+
// 2️⃣ Non-Solana fee settings
115+
MSG_VALUE = MSG_VALUE_DEFAULT
116+
GAS_LIMIT = GAS_LIMIT_DEFAULT
117+
}
118+
119+
// 6️⃣ send
120+
const _options = Options.newOptions().addExecutorLzReceiveOption(GAS_LIMIT, MSG_VALUE)
121+
const adapterParams: BytesLike = _options.toBytes()
122+
123+
const fees = await oft.estimateSendFee(dstEid, toBytes, amount, false, adapterParams)
124+
console.log(`fees[0] (wei): ${fees[0]} / (eth): ${hre.ethers.utils.formatEther(fees[0])}`)
125+
const tx = await oft.sendFrom(
126+
signer.address, // 'from' address to send tokens
127+
dstEid, // remote LayerZero chainId
128+
toBytes, // 'to' address to send tokens
129+
amountUnits, // amount of tokens to send (in wei)
130+
{
131+
refundAddress: signer.address,
132+
zroPaymentAddress: hre.ethers.constants.AddressZero,
133+
adapterParams: _options.toBytes(), // as workaround for EndpointV1 OFT -> OFT202, we specify options type 3 instead of adapter params
134+
},
135+
{ value: fees[0] }
136+
)
137+
const receipt = await tx.wait()
138+
const scanLink = getLayerZeroScanLink(receipt.transactionHash, srcEid === EndpointId.SOLANA_V2_TESTNET)
139+
140+
return {
141+
txHash: receipt.transactionHash,
142+
scanLink,
143+
}
144+
}

0 commit comments

Comments
 (0)