Skip to content

Commit 77169d1

Browse files
committed
chore(scripts): add PoC sol transfer with builder code
1 parent 8d75437 commit 77169d1

8 files changed

Lines changed: 1008 additions & 1 deletion

File tree

scripts/src/commands/sol/bridge/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { solVaultCommand } from "./sol-vault.command";
55
import {
66
bridgeCallCommand,
77
bridgeSolCommand,
8+
bridgeSolWithBcCommand,
89
bridgeSplCommand,
910
bridgeWrappedTokenCommand,
1011
wrapTokenCommand,
@@ -20,6 +21,7 @@ bridgeCommand.addCommand(solVaultCommand);
2021

2122
bridgeCommand.addCommand(bridgeCallCommand);
2223
bridgeCommand.addCommand(bridgeSolCommand);
24+
bridgeCommand.addCommand(bridgeSolWithBcCommand);
2325
bridgeCommand.addCommand(bridgeSplCommand);
2426
bridgeCommand.addCommand(bridgeWrappedTokenCommand);
2527
bridgeCommand.addCommand(wrapTokenCommand);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Command } from "commander";
2+
3+
import {
4+
getInteractiveConfirm,
5+
getOrPromptEvmAddress,
6+
getOrPromptDecimal,
7+
getOrPromptFilePath,
8+
getOrPromptDeployEnv,
9+
getOrPromptHex,
10+
getOrPromptInteger,
11+
validateAndExecute,
12+
getOrPromptHash,
13+
} from "@internal/utils/cli";
14+
import {
15+
argsSchema,
16+
handleBridgeSolWithBc,
17+
} from "./bridge-sol-with-bc.handler";
18+
19+
type CommanderOptions = {
20+
deployEnv?: string;
21+
to?: string;
22+
amount?: string;
23+
builderCode?: string;
24+
feeBps?: string;
25+
payerKp?: string;
26+
payForRelay?: boolean;
27+
};
28+
29+
async function collectInteractiveOptions(
30+
options: CommanderOptions
31+
): Promise<CommanderOptions> {
32+
let opts = { ...options };
33+
34+
if (!opts.deployEnv) {
35+
opts.deployEnv = await getOrPromptDeployEnv();
36+
}
37+
38+
opts.to = await getOrPromptEvmAddress(
39+
opts.to,
40+
"Enter user address on Base (recipient for hookData)"
41+
);
42+
43+
opts.amount = await getOrPromptDecimal(
44+
opts.amount,
45+
"Enter amount to bridge (in SOL)",
46+
0.001
47+
);
48+
49+
opts.builderCode = await getOrPromptHash(
50+
opts.builderCode,
51+
"Enter builder code (bytes32, 0x followed by 64 hex chars)"
52+
);
53+
54+
opts.feeBps = await getOrPromptInteger(
55+
opts.feeBps,
56+
"Enter fee in basis points (e.g., 100 for 1%)",
57+
0,
58+
10000
59+
);
60+
61+
opts.payerKp = await getOrPromptFilePath(
62+
opts.payerKp,
63+
"Enter payer keypair path (or 'config' for Solana CLI config)",
64+
["config"]
65+
);
66+
67+
if (opts.payForRelay === undefined) {
68+
opts.payForRelay = await getInteractiveConfirm(
69+
"Pay for relaying the message to Base?",
70+
true
71+
);
72+
}
73+
74+
return opts;
75+
}
76+
77+
export const bridgeSolWithBcCommand = new Command("bridge-sol-with-bc")
78+
.description("Bridge SOL from Solana to Base with Builder Code attribution")
79+
.option(
80+
"--deploy-env <deployEnv>",
81+
"Target deploy environment (testnet-alpha | testnet-prod | mainnet)"
82+
)
83+
.option("--to <address>", "User address on Base (for hookData)")
84+
.option("--amount <amount>", "Amount to bridge in SOL")
85+
.option(
86+
"--builder-code <hex>",
87+
"Builder code (bytes32, 0x followed by 64 hex chars)"
88+
)
89+
.option("--fee-bps <number>", "Fee in basis points (e.g., 100 for 1%)")
90+
.option(
91+
"--payer-kp <path>",
92+
"Payer keypair: 'config' or custom payer keypair path"
93+
)
94+
.option("--pay-for-relay", "Pay for relaying the message to Base")
95+
.action(async (options) => {
96+
const opts = await collectInteractiveOptions(options);
97+
await validateAndExecute(argsSchema, opts, handleBridgeSolWithBc);
98+
});
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { z } from "zod";
2+
import {
3+
getProgramDerivedAddress,
4+
type Instruction,
5+
createSolanaRpc,
6+
} from "@solana/kit";
7+
import { SYSTEM_PROGRAM_ADDRESS } from "@solana-program/system";
8+
import {
9+
toBytes,
10+
isAddress as isEvmAddress,
11+
encodeAbiParameters,
12+
encodeFunctionData,
13+
} from "viem";
14+
15+
import {
16+
CallType,
17+
fetchBridge,
18+
getBridgeSolInstruction,
19+
} from "@base/bridge/bridge";
20+
21+
import { logger } from "@internal/logger";
22+
import { FLYWHEEL_ABI } from "@internal/base/abi";
23+
import {
24+
buildAndSendTransaction,
25+
getSolanaCliConfigKeypairSigner,
26+
getKeypairSignerFromPath,
27+
getIdlConstant,
28+
relayMessageToBase,
29+
monitorMessageExecution,
30+
buildPayForRelayInstruction,
31+
outgoingMessagePubkey,
32+
solVaultPubkey,
33+
} from "@internal/sol";
34+
import { CONFIGS, DEPLOY_ENVS } from "@internal/constants";
35+
36+
const FLYWHEEL_ADDRESS = "0x00000f14ad09382841db481403d1775adee1179f" as const;
37+
const BRIDGE_CAMPAIGN_ADDRESS =
38+
"0x7626f7F9A574f526066acE9073518DaB1Bee038C" as const;
39+
40+
export const argsSchema = z.object({
41+
deployEnv: z
42+
.enum(DEPLOY_ENVS, {
43+
message:
44+
"Deploy environment must be 'testnet-alpha', 'testnet-prod', or 'mainnet'",
45+
})
46+
.default("testnet-prod"),
47+
to: z
48+
.string()
49+
.refine((value) => isEvmAddress(value), {
50+
message: "Invalid Base/Ethereum address format",
51+
})
52+
.brand<"baseAddress">(),
53+
amount: z
54+
.string()
55+
.transform((val) => parseFloat(val))
56+
.refine((val) => !isNaN(val) && val > 0, {
57+
message: "Amount must be a positive number",
58+
}),
59+
builderCode: z
60+
.string()
61+
.regex(/^0x[a-fA-F0-9]{64}$/, {
62+
message:
63+
"Builder code must be a valid bytes32 (0x followed by 64 hex characters)",
64+
})
65+
.brand<"builderCode">(),
66+
feeBps: z
67+
.string()
68+
.transform((val) => parseInt(val))
69+
.refine((val) => !isNaN(val) && val >= 0 && val <= 10000, {
70+
message: "Fee BPS must be a number between 0 and 10000",
71+
}),
72+
payerKp: z
73+
.union([z.literal("config"), z.string().brand<"payerKp">()])
74+
.default("config"),
75+
payForRelay: z.boolean().default(true),
76+
});
77+
78+
type Args = z.infer<typeof argsSchema>;
79+
type PayerKpArg = Args["payerKp"];
80+
81+
export async function handleBridgeSolWithBc(args: Args): Promise<void> {
82+
try {
83+
logger.info("--- Bridge SOL with Builder Code script ---");
84+
85+
const config = CONFIGS[args.deployEnv];
86+
const rpc = createSolanaRpc(config.solana.rpcUrl);
87+
logger.info(`RPC URL: ${config.solana.rpcUrl}`);
88+
89+
const payer = await resolvePayerKeypair(args.payerKp);
90+
logger.info(`Payer: ${payer.address}`);
91+
92+
const [bridgeAccountAddress] = await getProgramDerivedAddress({
93+
programAddress: config.solana.bridgeProgram,
94+
seeds: [Buffer.from(getIdlConstant("BRIDGE_SEED"))],
95+
});
96+
logger.info(`Bridge account: ${bridgeAccountAddress}`);
97+
98+
const bridge = await fetchBridge(rpc, bridgeAccountAddress);
99+
100+
const solVaultAddress = await solVaultPubkey(config.solana.bridgeProgram);
101+
logger.info(`Sol Vault: ${solVaultAddress}`);
102+
103+
// Calculate scaled amount (amount * 10^decimals)
104+
const scaledAmount = BigInt(Math.floor(args.amount * Math.pow(10, 9)));
105+
logger.info(`Amount: ${args.amount}`);
106+
logger.info(`Scaled amount: ${scaledAmount}`);
107+
108+
const { salt, pubkey: outgoingMessage } = await outgoingMessagePubkey(
109+
config.solana.bridgeProgram
110+
);
111+
logger.info(`Outgoing message: ${outgoingMessage}`);
112+
113+
// Builder Code logic
114+
logger.info(`User address (for hookData): ${args.to}`);
115+
logger.info(`Builder code: ${args.builderCode}`);
116+
logger.info(`Fee BPS: ${args.feeBps}`);
117+
118+
// 1. Build hookData = abi.encode(user, code, feeBps)
119+
const hookData = encodeAbiParameters(
120+
[
121+
{ type: "address", name: "user" },
122+
{ type: "bytes32", name: "code" },
123+
{ type: "uint16", name: "feeBps" },
124+
],
125+
[args.to as `0x${string}`, args.builderCode as `0x${string}`, args.feeBps]
126+
);
127+
logger.info(`Hook data: ${hookData}`);
128+
129+
// 2. Build call data for Flywheel.send(campaign, token, hookData)
130+
const wSolAddress = config.base.wSol;
131+
logger.info(`wSOL address: ${wSolAddress}`);
132+
logger.info(`Flywheel address: ${FLYWHEEL_ADDRESS}`);
133+
logger.info(`Bridge campaign address: ${BRIDGE_CAMPAIGN_ADDRESS}`);
134+
135+
const flywheelCallData = encodeFunctionData({
136+
abi: FLYWHEEL_ABI,
137+
functionName: "send",
138+
args: [BRIDGE_CAMPAIGN_ADDRESS, wSolAddress, hookData],
139+
});
140+
logger.info(`Flywheel call data: ${flywheelCallData}`);
141+
142+
// 3. Build the bridge instruction with call to Flywheel
143+
const ixs: Instruction[] = [
144+
getBridgeSolInstruction(
145+
{
146+
// Accounts
147+
payer,
148+
from: payer,
149+
gasFeeReceiver: bridge.data.gasConfig.gasFeeReceiver,
150+
solVault: solVaultAddress,
151+
bridge: bridgeAccountAddress,
152+
outgoingMessage,
153+
systemProgram: SYSTEM_PROGRAM_ADDRESS,
154+
155+
// Arguments
156+
outgoingMessageSalt: salt,
157+
to: toBytes(BRIDGE_CAMPAIGN_ADDRESS), // Send to campaign, not user
158+
amount: scaledAmount,
159+
call: {
160+
ty: CallType.Call,
161+
to: toBytes(FLYWHEEL_ADDRESS),
162+
value: 0n,
163+
data: Buffer.from(flywheelCallData.slice(2), "hex"),
164+
},
165+
},
166+
{ programAddress: config.solana.bridgeProgram }
167+
),
168+
];
169+
170+
if (args.payForRelay) {
171+
ixs.push(
172+
await buildPayForRelayInstruction(
173+
args.deployEnv,
174+
outgoingMessage,
175+
payer
176+
)
177+
);
178+
}
179+
180+
logger.info("Sending transaction...");
181+
const signature = await buildAndSendTransaction(
182+
{ type: "deploy-env", value: args.deployEnv },
183+
ixs,
184+
payer
185+
);
186+
logger.success("Bridge SOL with Builder Code operation completed!");
187+
logger.success(`Signature: ${signature}`);
188+
189+
if (args.payForRelay) {
190+
await monitorMessageExecution(args.deployEnv, outgoingMessage);
191+
} else {
192+
await relayMessageToBase(args.deployEnv, outgoingMessage);
193+
}
194+
} catch (error) {
195+
logger.error("Bridge SOL with Builder Code operation failed:", error);
196+
throw error;
197+
}
198+
}
199+
200+
async function resolvePayerKeypair(payerKpArg: PayerKpArg) {
201+
if (payerKpArg === "config") {
202+
logger.info("Using Solana CLI config for payer keypair");
203+
return await getSolanaCliConfigKeypairSigner();
204+
}
205+
206+
logger.info(`Using custom payer keypair: ${payerKpArg}`);
207+
return await getKeypairSignerFromPath(payerKpArg);
208+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./bridge-call.command";
22
export * from "./bridge-sol.command";
3+
export * from "./bridge-sol-with-bc.command";
34
export * from "./bridge-spl.command";
45
export * from "./bridge-wrapped-token.command";
56
export * from "./wrap-token.command";

0 commit comments

Comments
 (0)