Skip to content

Commit dd1f808

Browse files
authored
Merge pull request #4 from wormhole-foundation/feat/on-chain-quote-solana-support
feat: add Solana destination support to HelloWormholeOnChainQuote
2 parents 1d82d4e + 6424ac0 commit dd1f808

10 files changed

Lines changed: 487 additions & 64 deletions

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Cross-chain messaging with Wormhole Executor, demonstrating both **off-chain** a
44

55
This repo covers two use cases:
66
- **EVM ↔ EVM** — both off-chain and on-chain quotes, Sepolia ↔ Base Sepolia
7-
- **EVM ↔ Solana** — off-chain quotes only (see [Cross-VM section](#cross-vm-evm--solana))
7+
- **EVM ↔ Solana**both off-chain and on-chain quotes (see [Cross-VM section](#cross-vm-evm--solana))
88

99
> **License:** Code provided "AS IS", without warranties. Audit before mainnet deployment.
1010
@@ -30,8 +30,6 @@ function quoteGreeting(uint16 targetChain, uint128 gasLimit, address quoterAddre
3030
function sendGreeting(..., address quoterAddress) external payable;
3131
```
3232

33-
> **Note:** On-chain quotes currently support EVM destination chains only.
34-
> For EVM → Solana, use `HelloWormhole` (off-chain signed quotes) instead.
3533

3634
## Deployed Contracts (Testnet)
3735

@@ -150,7 +148,7 @@ npx tsx test.ts # Off-chain quote E2E
150148

151149
## Cross-VM: EVM ↔ Solana
152150

153-
Uses `HelloWormhole` (off-chain quotes) on-chain quotes do not yet support Solana destinations.
151+
Both `HelloWormhole` (off-chain quotes) and `HelloWormholeOnChainQuote` (on-chain quotes) support Solana destinations.
154152

155153
See the [Solana demo repo](https://github.com/wormhole-foundation/demo-hello-executor-solana) for the Solana-side implementation.
156154

@@ -179,11 +177,11 @@ const emitterPdaBytes32 = '0x' + Buffer.from(emitterPda.toBytes()).toString('hex
179177

180178
### Sending to Solana
181179

182-
Use `sendGreetingWithMsgValue` with `msgValue` in **lamports** to cover rent and fees on Solana:
180+
Use the overloaded `sendGreeting` with `msgValue` in **lamports** to cover rent and fees on Solana:
183181

184182
```solidity
185183
// msgValue covers rent + fees on Solana (~0.015 SOL = 15_000_000 lamports)
186-
sequence = sendGreetingWithMsgValue(
184+
sequence = sendGreeting(
187185
greeting,
188186
1, // Wormhole chain ID for Solana
189187
500000, // compute units (not gas)

e2e/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,16 @@ QUOTER_ADDRESS=0x5241c9276698439fef2780dbab76fec90b633fbd
2929
# Wormhole chain IDs reference: https://wormhole.com/docs/products/reference/chain-ids/
3030
# =============================================================================
3131

32+
# HelloWormhole (off-chain quotes) for EVM ↔ Solana
3233
# Deploy with: forge script script/HelloWormhole.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast
3334
# For mainnet: use a mainnet RPC and update CHAIN_ID_SEPOLIA in sendToSolana.ts
3435
HELLO_WORMHOLE_SEPOLIA_CROSSVM=0x...
3536

37+
# HelloWormholeOnChainQuote (on-chain quotes) for EVM ↔ Solana
38+
# Deploy with: forge script script/HelloWormholeOnChainQuote.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast
39+
# Then: forge script script/SetupSolanaPeerOnChainQuote.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast
40+
HELLO_WORMHOLE_OC_SEPOLIA_CROSSVM=0x...
41+
3642
# Solana peer addresses — see script/SetupSolanaPeer.s.sol for how to derive these
3743
# Update if you deployed your own Solana program (defaults point to the demo program)
3844
SOLANA_PROGRAM_ID_BYTES32=0x62cf7e5a219d24a831e51b2c2417fa898920b930fd1c6947f3a4fc8feec1020f

e2e/executor.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,15 @@ export async function pollForExecutorStatus(
225225
// Use SDK's fetchStatus function
226226
const status = await sdkDefs.fetchStatus(apiUrl, txHash, chain);
227227

228-
// fetchStatus returns an array of StatusResponse objects
229-
// An empty array means the transaction hasn't been seen yet
228+
// fetchStatus returns an array of StatusResponse objects.
229+
// Wait for a terminal status before returning.
230230
if (Array.isArray(status) && status.length > 0) {
231-
console.log(`\n✅ Executor has processed the transaction!`);
232-
return status;
231+
const s = status[0].status;
232+
if (['submitted', 'error', 'underpaid', 'aborted'].includes(s)) {
233+
console.log(`\n✅ Executor status: ${s}`);
234+
return status;
235+
}
236+
process.stdout.write(`(${s})`);
233237
}
234238
} catch (error) {
235239
// Ignore errors and continue polling

e2e/sendToSolana.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ const SOLANA_MSG_VALUE_LAMPORTS = 15_000_000n; // 0.015 SOL - a bit more for saf
3535

3636
// HelloWormhole ABI (just the functions we need)
3737
const ABI = [
38-
'function sendGreeting(string greeting, uint16 targetChain, uint128 gasLimit, uint256 totalCost, bytes signedQuote) external payable returns (uint64)',
39-
'function sendGreetingWithMsgValue(string greeting, uint16 targetChain, uint128 gasLimit, uint128 msgValue, uint256 totalCost, bytes signedQuote) external payable returns (uint64)',
38+
'function sendGreeting(string greeting, uint16 targetChain, uint128 gasLimit, uint128 msgValue, uint256 totalCost, bytes signedQuote) external payable returns (uint64)',
4039
'event GreetingSent(string greeting, uint16 targetChain, uint64 sequence)',
4140
];
4241

@@ -152,7 +151,7 @@ async function main() {
152151
// Send greeting with msgValue for Solana (in lamports)
153152
console.log('\nSending transaction with msgValue for Solana...');
154153
console.log(` msgValue: ${SOLANA_MSG_VALUE_LAMPORTS} lamports (${Number(SOLANA_MSG_VALUE_LAMPORTS) / 1e9} SOL)`);
155-
const tx = await contract.sendGreetingWithMsgValue(
154+
const tx = await contract.sendGreeting(
156155
greeting,
157156
CHAIN_ID_SOLANA,
158157
gasLimit,

e2e/sendToSolanaOnChainQuote.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Send greeting from Sepolia to Solana via Executor using ON-CHAIN QUOTE
4+
*
5+
* This uses the updated HelloWormholeOnChainQuote contract which supports
6+
* Solana destinations via overloaded sendGreeting + quoteGreeting (with msgValue).
7+
*
8+
* Flow:
9+
* 1. quoteGreeting(targetChain, gasLimit, msgValue, quoter) — get on-chain quote
10+
* 2. sendGreeting(greeting, targetChain, gasLimit, msgValue, totalCost, quoter) — publish + relay
11+
*
12+
* Usage:
13+
* npx tsx e2e/sendToSolanaOnChainQuote.ts "Hello from Sepolia (on-chain quote)!"
14+
*/
15+
16+
import { ethers } from 'ethers';
17+
import * as dotenv from 'dotenv';
18+
import { fileURLToPath } from 'url';
19+
import { dirname, join } from 'path';
20+
import { pollForExecutorStatus } from './executor.js';
21+
22+
const __filename = fileURLToPath(import.meta.url);
23+
const __dirname = dirname(__filename);
24+
dotenv.config({ path: join(__dirname, '.env') });
25+
26+
// Configuration
27+
const SEPOLIA_RPC = process.env.SEPOLIA_RPC_URL || 'https://ethereum-sepolia-rpc.publicnode.com';
28+
const PRIVATE_KEY = process.env.PRIVATE_KEY_SEPOLIA!;
29+
30+
// HelloWormholeOnChainQuote contract with Solana peer support.
31+
// Deploy with: forge script script/HelloWormholeOnChainQuote.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast
32+
// Then register Solana peer: forge script script/SetupSolanaPeerOnChainQuote.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast
33+
const HELLO_WORMHOLE_OC = process.env.HELLO_WORMHOLE_OC_SEPOLIA_CROSSVM!;
34+
if (!HELLO_WORMHOLE_OC) {
35+
throw new Error('HELLO_WORMHOLE_OC_SEPOLIA_CROSSVM not set. Deploy the contract and set this in e2e/.env');
36+
}
37+
38+
// Wormhole chain IDs — full reference: https://docs.wormhole.com/products/reference/chain-ids/
39+
const CHAIN_ID_SOLANA = 1;
40+
41+
// On-chain quoter address (operated by Wormhole)
42+
const QUOTER_ADDRESS = process.env.QUOTER_ADDRESS
43+
|| '0x5241c9276698439fef2780dbab76fec90b633fbd';
44+
45+
// Solana-specific: msgValue in LAMPORTS for rent + priority fees on the Solana side.
46+
// This is forwarded by the Executor when calling receive_greeting. Adjust upward
47+
// if delivery fails with insufficient funds for account creation.
48+
const SOLANA_MSG_VALUE_LAMPORTS = 15_000_000n; // ~0.015 SOL
49+
50+
// Gas limit for Solana execution (compute units)
51+
const GAS_LIMIT = 500_000n;
52+
53+
const ABI = [
54+
'function quoteGreeting(uint16 targetChain, uint128 gasLimit, uint128 msgValue, address quoterAddress) external view returns (uint256 totalCost)',
55+
'function sendGreeting(string calldata greeting, uint16 targetChain, uint128 gasLimit, uint128 msgValue, uint256 totalCost, address quoterAddress) external payable returns (uint64 sequence)',
56+
'function peers(uint16 chainId) external view returns (bytes32)',
57+
'event GreetingSent(string greeting, uint16 targetChain, uint64 sequence)',
58+
];
59+
60+
async function main() {
61+
const greeting = process.argv[2] || 'Hello from Sepolia (on-chain quote)!';
62+
63+
console.log('Sending Greeting: Sepolia -> Solana (On-Chain Quote)\n');
64+
console.log(`Message: "${greeting}"`);
65+
66+
const provider = new ethers.JsonRpcProvider(SEPOLIA_RPC);
67+
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
68+
console.log(`Wallet: ${wallet.address}`);
69+
70+
const balance = await provider.getBalance(wallet.address);
71+
console.log(`Balance: ${ethers.formatEther(balance)} ETH`);
72+
73+
const contract = new ethers.Contract(HELLO_WORMHOLE_OC, ABI, wallet);
74+
75+
// Verify peer is set
76+
const peer = await contract.peers(CHAIN_ID_SOLANA);
77+
if (peer === ethers.ZeroHash) {
78+
throw new Error('No Solana peer registered on contract. Run SetupSolanaPeer first.');
79+
}
80+
console.log(`\nSolana peer: ${peer}`);
81+
82+
// Step 1: Get on-chain quote
83+
console.log('\nGetting on-chain quote...');
84+
console.log(` Gas limit: ${GAS_LIMIT}`);
85+
console.log(` msgValue: ${SOLANA_MSG_VALUE_LAMPORTS} lamports (${Number(SOLANA_MSG_VALUE_LAMPORTS) / 1e9} SOL)`);
86+
console.log(` Quoter: ${QUOTER_ADDRESS}`);
87+
88+
const totalCost = await contract.quoteGreeting(
89+
CHAIN_ID_SOLANA,
90+
GAS_LIMIT,
91+
SOLANA_MSG_VALUE_LAMPORTS,
92+
QUOTER_ADDRESS,
93+
);
94+
95+
console.log(` Total cost: ${ethers.formatEther(totalCost)} ETH (${totalCost} wei)`);
96+
97+
if (balance < totalCost) {
98+
throw new Error(`Insufficient balance: have ${ethers.formatEther(balance)}, need ${ethers.formatEther(totalCost)}`);
99+
}
100+
101+
// Step 2: Send greeting
102+
console.log('\nSending greeting with on-chain quote...');
103+
const tx = await contract.sendGreeting(
104+
greeting,
105+
CHAIN_ID_SOLANA,
106+
GAS_LIMIT,
107+
SOLANA_MSG_VALUE_LAMPORTS,
108+
totalCost,
109+
QUOTER_ADDRESS,
110+
{ value: totalCost },
111+
);
112+
113+
console.log(`TX Hash: ${tx.hash}`);
114+
console.log(`Explorer: https://sepolia.etherscan.io/tx/${tx.hash}`);
115+
116+
console.log('\nWaiting for confirmation...');
117+
const receipt = await tx.wait();
118+
console.log(`Confirmed in block ${receipt.blockNumber}`);
119+
120+
// Parse GreetingSent event (filter by contract address, then decode)
121+
const iface = new ethers.Interface(ABI);
122+
const sentEvent = receipt.logs
123+
.filter((log: any) => log.address.toLowerCase() === HELLO_WORMHOLE_OC.toLowerCase())
124+
.map((log: any) => iface.parseLog({ topics: log.topics as string[], data: log.data }))
125+
.find((event: any) => event?.name === 'GreetingSent');
126+
127+
if (sentEvent) {
128+
console.log(`\nGreetingSent event:`);
129+
console.log(` Message: ${sentEvent.args[0]}`);
130+
console.log(` Target Chain: ${sentEvent.args[1]}`);
131+
console.log(` Sequence: ${sentEvent.args[2]}`);
132+
}
133+
134+
// Poll executor status
135+
const executorStatus = await pollForExecutorStatus(
136+
'Sepolia',
137+
receipt.hash,
138+
'Testnet',
139+
120000,
140+
);
141+
142+
if (Array.isArray(executorStatus)) {
143+
const relay = executorStatus[0];
144+
if (relay?.status === 'submitted' && relay.txs?.length) {
145+
console.log('\nSUCCESS! Message delivered to Solana!');
146+
console.log(` Solana TX: ${relay.txs[0].txHash}`);
147+
console.log(` https://explorer.solana.com/tx/${relay.txs[0].txHash}?cluster=devnet`);
148+
} else if (relay?.status === 'error' || relay?.status === 'aborted') {
149+
console.log(`\nRelay failed: ${relay.failureCause || relay.error || relay.status}`);
150+
} else if (relay?.status === 'underpaid') {
151+
console.log('\nRelay underpaid. Try increasing GAS_LIMIT or SOLANA_MSG_VALUE_LAMPORTS.');
152+
} else {
153+
console.log(`\nRelay status: ${relay?.status || 'unknown'}`);
154+
}
155+
} else {
156+
console.log('\nExecutor delivery not confirmed within timeout.');
157+
}
158+
159+
console.log('\n' + '-'.repeat(60));
160+
console.log('Links:');
161+
console.log(` Sepolia TX: https://sepolia.etherscan.io/tx/${receipt.hash}`);
162+
console.log(` Wormholescan: https://wormholescan.io/#/tx/${receipt.hash}?network=Testnet`);
163+
console.log(` Executor: https://wormholelabs-xyz.github.io/executor-explorer/#/tx/${receipt.hash}?endpoint=https%3A%2F%2Fexecutor-testnet.labsapis.com&env=Testnet`);
164+
}
165+
166+
main().catch((error) => {
167+
console.error('\nError:', error.message || error);
168+
process.exit(1);
169+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.22;
3+
4+
import {Script, console} from "forge-std/Script.sol";
5+
import {HelloWormholeOnChainQuote} from "src/HelloWormholeOnChainQuote.sol";
6+
7+
/**
8+
* @title SetupSolanaPeerOnChainQuote
9+
* @notice Sets up a Solana program as a peer on the HelloWormholeOnChainQuote contract
10+
*
11+
* For SVM ↔ EVM messaging, peer registration requires TWO separate addresses:
12+
*
13+
* 1. peers[Solana] = PROGRAM ID (bytes32, no padding)
14+
* Used by the Executor to route relay requests — must be an executable account.
15+
*
16+
* 2. vaaEmitters[Solana] = EMITTER PDA (bytes32, no padding)
17+
* Used to verify incoming VAAs from Solana — the PDA that signs Wormhole messages.
18+
* Derived as: PDA(["emitter"], programId)
19+
*
20+
* To compute these values from a Solana program ID (TypeScript):
21+
*
22+
* const programId = new PublicKey("7eiTqf1b1dNwpzn27qEr4eGSWnuon2fJTbnTuWcFifZG");
23+
* const [emitterPda] = PublicKey.findProgramAddressSync([Buffer.from("emitter")], programId);
24+
*
25+
* const programIdBytes32 = '0x' + Buffer.from(programId.toBytes()).toString('hex');
26+
* const emitterPdaBytes32 = '0x' + Buffer.from(emitterPda.toBytes()).toString('hex');
27+
*
28+
* Usage:
29+
* export HELLO_WORMHOLE_OC_SEPOLIA_CROSSVM=0x...
30+
* export SOLANA_PROGRAM_ID_BYTES32=0x... (program ID as bytes32, no padding)
31+
* export SOLANA_EMITTER_PDA_BYTES32=0x... (emitter PDA as bytes32, no padding)
32+
* forge script script/SetupSolanaPeerOnChainQuote.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast
33+
*/
34+
contract SetupSolanaPeerOnChainQuoteScript is Script {
35+
// Wormhole chain ID for Solana
36+
uint16 constant CHAIN_ID_SOLANA = 1;
37+
38+
// Default: program ID of 7eiTqf1b1dNwpzn27qEr4eGSWnuon2fJTbnTuWcFifZG (bytes32, no padding)
39+
bytes32 constant DEFAULT_SOLANA_PROGRAM_ID = 0x62cf7e5a219d24a831e51b2c2417fa898920b930fd1c6947f3a4fc8feec1020f;
40+
41+
// Default: emitter PDA of program 7eiTqf1b1dNwpzn27qEr4eGSWnuon2fJTbnTuWcFifZG
42+
// Derived via: PublicKey.findProgramAddressSync([Buffer.from("emitter")], programId)
43+
bytes32 constant DEFAULT_SOLANA_EMITTER_PDA = 0x58235d29729e44920df367836a92ab77fcee36b7a27b03304cd699f5eb0efae5;
44+
45+
function setUp() public {}
46+
47+
function run() public {
48+
address localContract = vm.envAddress("HELLO_WORMHOLE_OC_SEPOLIA_CROSSVM");
49+
50+
bytes32 solanaProgramId;
51+
try vm.envBytes32("SOLANA_PROGRAM_ID_BYTES32") returns (bytes32 val) {
52+
solanaProgramId = val;
53+
} catch {
54+
solanaProgramId = DEFAULT_SOLANA_PROGRAM_ID;
55+
}
56+
57+
bytes32 solanaEmitterPda;
58+
try vm.envBytes32("SOLANA_EMITTER_PDA_BYTES32") returns (bytes32 val) {
59+
solanaEmitterPda = val;
60+
} catch {
61+
solanaEmitterPda = DEFAULT_SOLANA_EMITTER_PDA;
62+
}
63+
64+
console.log("Setting up Solana peer on HelloWormholeOnChainQuote:", localContract);
65+
console.log(" programId:", vm.toString(solanaProgramId));
66+
console.log(" emitterPDA:", vm.toString(solanaEmitterPda));
67+
68+
vm.startBroadcast();
69+
70+
HelloWormholeOnChainQuote hello = HelloWormholeOnChainQuote(localContract);
71+
72+
// Register program ID for executor routing (dstAddr in relay requests)
73+
hello.setPeer(CHAIN_ID_SOLANA, solanaProgramId);
74+
75+
// Register emitter PDA for incoming VAA verification
76+
hello.setVaaEmitter(CHAIN_ID_SOLANA, solanaEmitterPda);
77+
78+
console.log("Done.");
79+
80+
vm.stopBroadcast();
81+
}
82+
}

0 commit comments

Comments
 (0)