diff --git a/crates/sdk-node/README.md b/crates/sdk-node/README.md index 5982dfcab..6be592be4 100644 --- a/crates/sdk-node/README.md +++ b/crates/sdk-node/README.md @@ -82,6 +82,27 @@ const client = await createClient() .use(surfpool({ rpcUrl: "http://127.0.0.1:8899" })); ``` +That payer is usually unfunded on the running Surfnet. `airdropAddresses` tops +up each listed address or signer while the client is composed, so no separate +cheatcode call is needed before sending a transaction: + +```ts +const client = await createClient() + .use(payer(myPayer)) + .use( + surfpool({ + airdropAddresses: [myPayer, someRecipient], + airdropAmount: 5_000_000_000n, // lamports, defaults to 10 SOL + rpcUrl: "http://127.0.0.1:8899", + }), + ); +``` + +Funding is a top-up: an address already holding at least `airdropAmount` is +left alone, and only the lamport balance is written, so existing account data +and owner survive. A failure to fund throws, naming the address. The option +works in embedded mode too, alongside the pre-funded payer. + For one-off use without a client, `createSurfnetCheatcodesRpc(url)` returns a standalone `Rpc`, and `surfnetCheatcodes()` installs `client.cheatcodes` on any existing client. diff --git a/crates/sdk-node/scripts/kit-smoke.js b/crates/sdk-node/scripts/kit-smoke.js index b562312ba..23c5362e7 100644 --- a/crates/sdk-node/scripts/kit-smoke.js +++ b/crates/sdk-node/scripts/kit-smoke.js @@ -61,6 +61,24 @@ test("embedded surfpool() boots a Surfnet and wires the full kit client", async assert.equal(funded.value, 1_000_000_000n); }); +test("embedded surfpool() airdrops configured addresses at startup", async (t) => { + const recipient = Surfnet.newKeypair().publicKey; + const signerLike = { address: Surfnet.newKeypair().publicKey }; + const client = await createClient().use( + surfpool({ + airdropAddresses: [recipient, signerLike], + airdropAmount: 3_000_000_000n, + surfnet: { offline: true }, + }), + ); + t.after(() => client.surfnet.stop()); + + const funded = await client.rpc.getBalance(recipient).send(); + assert.equal(funded.value, 3_000_000_000n); + const fundedSigner = await client.rpc.getBalance(signerLike.address).send(); + assert.equal(fundedSigner.value, 3_000_000_000n); +}); + test("disposing the embedded client stops the Surfnet", async () => { const client = await createClient().use(surfpool({ surfnet: { offline: true } })); await client.rpc.getSlot().send(); diff --git a/crates/sdk-node/scripts/kit-unit.js b/crates/sdk-node/scripts/kit-unit.js index 43ccdfd48..71222dc7c 100644 --- a/crates/sdk-node/scripts/kit-unit.js +++ b/crates/sdk-node/scripts/kit-unit.js @@ -29,6 +29,10 @@ function mockFetch(handler) { }; } +function fakePayer() { + return { address: "SurfpoolTestPayer11111111111111111111111111" }; +} + test("cheatcodes RPC prefixes method names and unwraps { context, value } envelopes", async () => { const seenMethods = []; const restore = mockFetch((request) => { @@ -179,8 +183,8 @@ test("cheatcodes RPC sends configured extra headers", async () => { }); test("attach mode installs the full client surface without loading the native module", () => { - const fakePayer = { address: "SurfpoolTestPayer11111111111111111111111111" }; - const client = createClient({ payer: fakePayer }).use( + const payer = fakePayer(); + const client = createClient({ payer }).use( surfpool({ rpcUrl: "http://127.0.0.1:8899" }), ); @@ -196,26 +200,26 @@ test("attach mode installs the full client surface without loading the native mo assert.equal(typeof client.sendTransaction, "function"); assert.equal(typeof client.sendTransactions, "function"); assert.equal(client.surfnet, undefined); - assert.equal(client.payer, fakePayer); + assert.equal(client.payer, payer); }); test("attach mode defaults the WebSocket URL to surfpool's default WS port", () => { - const fakePayer = { address: "SurfpoolTestPayer11111111111111111111111111" }; + const payer = fakePayer(); // Surfpool's WebSocket port (default 8900) is independent of its HTTP // port, so a custom --port keeps subscriptions on 8900. - const customPort = createClient({ payer: fakePayer }).use( + const customPort = createClient({ payer }).use( surfpool({ rpcUrl: "http://127.0.0.1:12345" }), ); assert.equal(customPort.wsUrl, "ws://127.0.0.1:8900"); // Port-less URLs (e.g. behind a proxy) only swap the protocol. - const proxied = createClient({ payer: fakePayer }).use( + const proxied = createClient({ payer }).use( surfpool({ rpcUrl: "https://surfpool.example.com" }), ); assert.equal(proxied.wsUrl, "wss://surfpool.example.com"); - const explicit = createClient({ payer: fakePayer }).use( + const explicit = createClient({ payer }).use( surfpool({ rpcUrl: "http://127.0.0.1:12345", rpcSubscriptionsUrl: "ws://127.0.0.1:54321", @@ -224,6 +228,128 @@ test("attach mode defaults the WebSocket URL to surfpool's default WS port", () assert.equal(explicit.wsUrl, "ws://127.0.0.1:54321"); }); +test("attach mode without airdropAddresses stays synchronous and funds nothing", () => { + const calls = []; + const restore = mockFetch((request) => { + calls.push(request.method); + return { result: { context: { slot: 1 }, value: null } }; + }); + try { + const client = createClient({ payer: fakePayer() }).use( + surfpool({ rpcUrl: ENDPOINT }), + ); + assert.equal(typeof client.then, "undefined"); + assert.deepEqual(calls, []); + } finally { + restore(); + } +}); + +test("attach mode airdrops configured addresses, accepting signers and bare addresses", async () => { + const funded = new Map(); + const restore = mockFetch((request) => { + if (request.method === "getBalance") { + return { result: { context: { slot: 1 }, value: 0 } }; + } + if (request.method === "surfnet_setAccount") { + funded.set(request.params[0], request.params[1].lamports); + return { result: { context: { slot: 1 }, value: null } }; + } + throw new Error(`unexpected method ${request.method}`); + }); + try { + const payer = fakePayer(); + const other = "SurfpoolTestOther111111111111111111111111111"; + const client = await createClient({ payer }).use( + surfpool({ airdropAddresses: [payer, other], rpcUrl: ENDPOINT }), + ); + + assert.equal(client.rpcUrl, ENDPOINT); + assert.equal(typeof client.cheatcodes.setAccount, "function"); + // 10 SOL by default, matching Surfnet's own startup airdrop. + assert.equal(funded.get(payer.address), 10_000_000_000); + assert.equal(funded.get(other), 10_000_000_000); + } finally { + restore(); + } +}); + +test("attach mode honors airdropAmount and skips addresses already holding enough", async () => { + const balances = { + SurfpoolTestOther111111111111111111111111111: 5_000_000_000, + SurfpoolTestPayer11111111111111111111111111: 0, + }; + const funded = []; + const restore = mockFetch((request) => { + if (request.method === "getBalance") { + return { result: { context: { slot: 1 }, value: balances[request.params[0]] } }; + } + funded.push(request.params[0]); + return { result: { context: { slot: 1 }, value: null } }; + }); + try { + const payer = fakePayer(); + await createClient({ payer }).use( + surfpool({ + airdropAddresses: [payer, "SurfpoolTestOther111111111111111111111111111"], + airdropAmount: 2_000_000_000n, + rpcUrl: ENDPOINT, + }), + ); + assert.deepEqual(funded, [payer.address]); + } finally { + restore(); + } +}); + +test("attach mode rejects airdropAmount values that cannot represent a lamport top-up", async () => { + const restore = mockFetch(() => { + throw new Error("no request should be made for an invalid amount"); + }); + try { + for (const airdropAmount of [Number.MAX_SAFE_INTEGER + 2, 1.5]) { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], airdropAmount, rpcUrl: ENDPOINT }), + ), + /airdropAmount must be a safe integer or a bigint/, + ); + } + // A negative amount is below every balance, so it would silently fund nothing. + for (const airdropAmount of [-1, -1n]) { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], airdropAmount, rpcUrl: ENDPOINT }), + ), + /airdropAmount must not be negative/, + ); + } + } finally { + restore(); + } +}); + +test("attach mode airdrop failures reject with the offending address", async () => { + const restore = mockFetch((request) => + request.method === "getBalance" + ? { result: { context: { slot: 1 }, value: 0 } } + : { error: { code: -32601, message: "cheatcode disabled" } }, + ); + try { + const payer = fakePayer(); + await assert.rejects( + createClient({ payer }).use( + surfpool({ airdropAddresses: [payer], rpcUrl: ENDPOINT }), + ), + /Failed to airdrop 10000000000 lamports to SurfpoolTestPayer11111111111111111111111111/, + ); + } finally { + restore(); + } +}); + test("ESM and CJS builds expose the same named exports", async () => { const esm = await import("@solana/surfpool/kit"); const cjsKeys = Object.keys(kit).filter((k) => k !== "__esModule"); diff --git a/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts b/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts index a8d1ba95b..d3e11662b 100644 --- a/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts +++ b/crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts @@ -3,7 +3,7 @@ * the emitting builds; checked by `npm run typecheck:kit`. Each * `@ts-expect-error` documents a misuse the types must keep rejecting. */ -import { createClient, type KeyPairSigner } from '@solana/kit'; +import { type Address, createClient, type KeyPairSigner } from '@solana/kit'; import { surfpool } from '../surfpool.js'; @@ -34,6 +34,41 @@ void (async () => { // @ts-expect-error attach mode has no native Surfnet handle. void attached.surfnet; }); +// Attach mode without funding stays synchronous. +void (() => { + const attached = createClient({ payer: payerSigner }).use(surfpool({ rpcUrl: 'http://127.0.0.1:8899' })); + void attached.rpc.getSlot(); +}); + +// Attach mode with `airdropAddresses` becomes asynchronous. +void (async () => { + const attached = await createClient({ payer: payerSigner }).use( + surfpool({ + airdropAddresses: [payerSigner, '11111111111111111111111111111111' as Address], + airdropAmount: 1_000_000_000n, + rpcUrl: 'http://127.0.0.1:8899', + }), + ); + void attached.rpc.getSlot(); +}); +// @ts-expect-error airdrop targets must be addresses or carry one. +void surfpool({ airdropAddresses: [42], rpcUrl: 'http://127.0.0.1:8899' }); + +declare const shouldFund: boolean; +// A possibly-present `airdropAddresses` is rejected rather than typed as the +// synchronous plugin it would not be at runtime. +// @ts-expect-error the funding decision must be made at the type level. +void surfpool({ + airdropAddresses: shouldFund ? [payerSigner] : undefined, + rpcUrl: 'http://127.0.0.1:8899', +}); +const conditionalConfig = { + rpcUrl: 'http://127.0.0.1:8899', + ...(shouldFund ? { airdropAddresses: [payerSigner] } : {}), +}; +// @ts-expect-error same, spread into the config rather than written inline. +void surfpool(conditionalConfig); + // @ts-expect-error attach mode requires the client to already have a payer. void createClient().use(surfpool({ rpcUrl: 'http://127.0.0.1:8899' })); // @ts-expect-error embedded startup options cannot be combined with attach mode. diff --git a/crates/sdk-node/surfpool-sdk/kit/index.ts b/crates/sdk-node/surfpool-sdk/kit/index.ts index 5733a6e90..41c88bb91 100644 --- a/crates/sdk-node/surfpool-sdk/kit/index.ts +++ b/crates/sdk-node/surfpool-sdk/kit/index.ts @@ -1,7 +1,9 @@ export { createSurfnetCheatcodesRpc, DEFAULT_SURFNET_ENDPOINT, surfnetCheatcodes } from './cheatcodes.js'; export { surfpool } from './surfpool.js'; export type { + AirdropTarget, SurfpoolAttachConfig, + SurfpoolAttachConfigWithAirdrop, SurfpoolConfig, SurfpoolEmbeddedConfig, SurfpoolRpcOptions, diff --git a/crates/sdk-node/surfpool-sdk/kit/surfpool.ts b/crates/sdk-node/surfpool-sdk/kit/surfpool.ts index 44b4de5c3..10d37ed9e 100644 --- a/crates/sdk-node/surfpool-sdk/kit/surfpool.ts +++ b/crates/sdk-node/surfpool-sdk/kit/surfpool.ts @@ -1,9 +1,22 @@ -import { type ClientWithPayer, createKeyPairSignerFromBytes, extendClient, pipe, withCleanup } from '@solana/kit'; +import { + type Address, + type ClientWithPayer, + createKeyPairSignerFromBytes, + extendClient, + pipe, + withCleanup, +} from '@solana/kit'; import { solanaLocalRpc, type SolanaRpcConfig } from '@solana/kit-plugin-rpc'; import type { SurfnetConfig } from '@solana/surfpool'; import { createSurfnetCheatcodesRpc } from './cheatcodes.js'; +/** Lamports each `airdropAddresses` entry is topped up to when no amount is given. */ +const DEFAULT_AIRDROP_LAMPORTS = 10_000_000_000n; + +/** An address to fund, or anything carrying one (a signer, a PDA, an account). */ +export type AirdropTarget = Address | { readonly address: Address }; + /** * Transaction planner/executor and RPC options forwarded to the standard * local-cluster Solana RPC plugin. URLs are excluded because they are @@ -11,35 +24,112 @@ import { createSurfnetCheatcodesRpc } from './cheatcodes.js'; */ export type SurfpoolRpcOptions = Omit, 'rpcSubscriptionsUrl' | 'rpcUrl'>; -/** Configuration for {@link surfpool} in embedded mode (boots an in-process Surfnet). */ -export type SurfpoolEmbeddedConfig = SurfpoolRpcOptions & { - rpcSubscriptionsUrl?: never; - rpcUrl?: never; - /** Startup options forwarded verbatim to `Surfnet.startWithConfig()`. */ - surfnet?: SurfnetConfig; +/** Startup funding applied to both modes. */ +type SurfpoolAirdropOptions = { + /** + * Addresses (or signers) topped up to {@link SurfpoolAirdropOptions.airdropAmount} + * lamports while the client is being composed. Addresses already holding at + * least that much are left alone. + */ + airdropAddresses?: readonly AirdropTarget[]; + /** + * Lamports to fund each entry of `airdropAddresses` with. Defaults to 10 SOL. + * A `number` must be a safe integer; pass a `bigint` for amounts above 2^53. + */ + airdropAmount?: bigint | number; }; +/** Configuration for {@link surfpool} in embedded mode (boots an in-process Surfnet). */ +export type SurfpoolEmbeddedConfig = SurfpoolAirdropOptions & + SurfpoolRpcOptions & { + rpcSubscriptionsUrl?: never; + rpcUrl?: never; + /** Startup options forwarded verbatim to `Surfnet.startWithConfig()`. */ + surfnet?: SurfnetConfig; + }; + /** Configuration for {@link surfpool} in attach mode (connects to a running Surfpool). */ -export type SurfpoolAttachConfig = SurfpoolRpcOptions & { - /** - * The WebSocket URL of the running Surfpool instance. When omitted and - * the `rpcUrl` has an explicit port, defaults to Surfpool's default - * WebSocket port (8900, `--ws-port`) on the same host — Surfpool's - * WebSocket port is independent of its HTTP port. For a `rpcUrl` without - * a port (e.g. behind a proxy), only the protocol is swapped to - * `ws`/`wss`. Set this explicitly when your setup differs. - */ - rpcSubscriptionsUrl?: string; - /** The HTTP RPC URL of a running Surfpool instance to attach to. */ - rpcUrl: string; - surfnet?: never; +export type SurfpoolAttachConfig = SurfpoolAirdropOptions & + SurfpoolRpcOptions & { + /** + * The WebSocket URL of the running Surfpool instance. When omitted and + * the `rpcUrl` has an explicit port, defaults to Surfpool's default + * WebSocket port (8900, `--ws-port`) on the same host — Surfpool's + * WebSocket port is independent of its HTTP port. For a `rpcUrl` without + * a port (e.g. behind a proxy), only the protocol is swapped to + * `ws`/`wss`. Set this explicitly when your setup differs. + */ + rpcSubscriptionsUrl?: string; + /** The HTTP RPC URL of a running Surfpool instance to attach to. */ + rpcUrl: string; + surfnet?: never; + }; + +/** Attach-mode configuration that funds addresses, making the plugin asynchronous. */ +export type SurfpoolAttachConfigWithAirdrop = SurfpoolAttachConfig & { + airdropAddresses: readonly AirdropTarget[]; }; export type SurfpoolConfig = SurfpoolAttachConfig | SurfpoolEmbeddedConfig; +/** + * A `number` above `Number.MAX_SAFE_INTEGER` has already lost precision by the + * time it is read, and a fractional one is not a lamport amount at all. A + * negative amount is below every balance, so it would skip funding entirely + * rather than do what it says. All three are rejected instead of silently + * funding something other than what was asked for. + */ +function toLamports(amount: bigint | number = DEFAULT_AIRDROP_LAMPORTS): bigint { + if (typeof amount === 'number' && !Number.isSafeInteger(amount)) { + throw new Error(`airdropAmount must be a safe integer or a bigint; received ${amount}`); + } + const lamports = BigInt(amount); + if (lamports < 0n) { + throw new Error(`airdropAmount must not be negative; received ${amount}`); + } + return lamports; +} + +/** + * Tops each target up to `amount` lamports through the `setAccount` cheatcode, + * leaving any account that already holds at least that much untouched. Only + * the lamport balance is written, so an existing account keeps its data and + * owner. + */ +async function fundAirdropAddresses( + client: { + cheatcodes: ReturnType; + rpc: { getBalance: (address: Address) => { send: () => Promise<{ value: bigint }> } }; + }, + targets: readonly AirdropTarget[], + amount: bigint, +): Promise { + await Promise.all( + targets.map(async target => { + const address = typeof target === 'string' ? target : target.address; + try { + const { value: balance } = await client.rpc.getBalance(address).send(); + if (balance >= amount) { + return; + } + await client.cheatcodes.setAccount(address, { lamports: amount }).send(); + } catch (error) { + throw new Error(`Failed to airdrop ${amount} lamports to ${address}`, { cause: error }); + } + }), + ); +} + function surfpoolEmbedded(config: SurfpoolEmbeddedConfig = {}) { return async (client: T) => { - const { rpcSubscriptionsUrl: _unusedWs, rpcUrl: _unusedRpc, surfnet: surfnetConfig, ...rpcOptions } = config; + const { + airdropAddresses, + airdropAmount, + rpcSubscriptionsUrl: _unusedWs, + rpcUrl: _unusedRpc, + surfnet: surfnetConfig, + ...rpcOptions + } = config; // Lazy imports keep the optional peers optional: the native module is // only needed in embedded mode, and the signer package is only needed // for the payer this mode installs. @@ -66,6 +156,10 @@ function surfpoolEmbedded(config: SurfpoolEmbeddedConfig = {}) { }), ); + if (airdropAddresses?.length) { + await fundAirdropAddresses(configuredClient, airdropAddresses, toLamports(airdropAmount)); + } + // Disposing the client stops the in-process Surfnet so its servers // and ports are freed; recreating the client boots a fresh one. if (typeof DisposableStack !== 'undefined') { @@ -103,7 +197,14 @@ function surfpoolEmbedded(config: SurfpoolEmbeddedConfig = {}) { function surfpoolAttach(config: SurfpoolAttachConfig) { return (client: T) => { - const { rpcSubscriptionsUrl, rpcUrl, surfnet: _unusedSurfnet, ...rpcOptions } = config; + const { + airdropAddresses: _unusedAirdropAddresses, + airdropAmount: _unusedAirdropAmount, + rpcSubscriptionsUrl, + rpcUrl, + surfnet: _unusedSurfnet, + ...rpcOptions + } = config; const wsUrl = rpcSubscriptionsUrl ?? deriveSubscriptionsUrl(rpcUrl); return pipe( @@ -121,6 +222,15 @@ function surfpoolAttach(config: SurfpoolAttachConfig) { }; } +function surfpoolAttachFunded(config: SurfpoolAttachConfigWithAirdrop) { + const attach = surfpoolAttach(config); + return async (client: T) => { + const configuredClient = attach(client); + await fundAirdropAddresses(configuredClient, config.airdropAddresses, toLamports(config.airdropAmount)); + return configuredClient; + }; +} + /** * Kit plugin for Surfpool. A drop-in replacement for `solanaLocalRpc()` or * `litesvm()` backed by a Surfpool Surfnet. @@ -138,7 +248,10 @@ function surfpoolAttach(config: SurfpoolAttachConfig) { * **Attach mode** (when `rpcUrl` is set): connects to an already-running * Surfpool instance (e.g. `surfpool start`) instead of booting one. No native * module is loaded, no `payer` is installed (the client must already have - * one), and there is no `client.surfnet` handle. + * one), and there is no `client.surfnet` handle. Because that payer is usually + * unfunded on the running Surfnet, `airdropAddresses` tops it (and anything + * else listed) up to `airdropAmount` lamports as the client is composed; the + * plugin then returns a promise, so `.use()` must be awaited. * * @example Embedded * ```ts @@ -154,19 +267,29 @@ function surfpoolAttach(config: SurfpoolAttachConfig) { * ```ts * const client = await createClient() * .use(payer(myPayer)) - * .use(surfpool({ rpcUrl: 'http://127.0.0.1:8899' })); + * .use(surfpool({ airdropAddresses: [myPayer], rpcUrl: 'http://127.0.0.1:8899' })); * ``` */ export function surfpool(config?: SurfpoolEmbeddedConfig): ReturnType; -export function surfpool(config: SurfpoolAttachConfig): ReturnType; +export function surfpool(config: SurfpoolAttachConfigWithAirdrop): ReturnType; +export function surfpool( + config: SurfpoolAttachConfig & { airdropAddresses?: never }, +): ReturnType; export function surfpool(config: SurfpoolConfig = {}) { - return isAttachConfig(config) ? surfpoolAttach(config) : surfpoolEmbedded(config); + if (!isAttachConfig(config)) { + return surfpoolEmbedded(config); + } + return hasAirdropAddresses(config) ? surfpoolAttachFunded(config) : surfpoolAttach(config); } function isAttachConfig(config: SurfpoolConfig): config is SurfpoolAttachConfig { return typeof config.rpcUrl === 'string'; } +function hasAirdropAddresses(config: SurfpoolAttachConfig): config is SurfpoolAttachConfigWithAirdrop { + return config.airdropAddresses !== undefined; +} + function deriveSubscriptionsUrl(rpcUrl: string): string { // Surfpool serves WebSocket subscriptions on its own port (default 8900, // `--ws-port`), independent of the HTTP port. A protocol-swapped copy of