Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/sdk-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SurfnetCheatcodesApi>`, and `surfnetCheatcodes()` installs
`client.cheatcodes` on any existing client.
Expand Down
18 changes: 18 additions & 0 deletions crates/sdk-node/scripts/kit-smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
111 changes: 104 additions & 7 deletions crates/sdk-node/scripts/kit-unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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" }),
);

Expand All @@ -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",
Expand All @@ -224,6 +228,99 @@ 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 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");
Expand Down
37 changes: 36 additions & 1 deletion crates/sdk-node/surfpool-sdk/kit/__typetests__/typetests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions crates/sdk-node/surfpool-sdk/kit/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export { createSurfnetCheatcodesRpc, DEFAULT_SURFNET_ENDPOINT, surfnetCheatcodes } from './cheatcodes.js';
export { surfpool } from './surfpool.js';
export type {
AirdropTarget,
Comment thread
amilz marked this conversation as resolved.
SurfpoolAttachConfig,
SurfpoolAttachConfigWithAirdrop,
SurfpoolConfig,
SurfpoolEmbeddedConfig,
SurfpoolRpcOptions,
Expand Down
Loading
Loading