Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions .changeset/cool-signs-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@solana/plugin-interfaces': minor
---

Add a `ClientWithTransactionSigning` interface providing `signTransaction` and `signTransactions`. These accept the same flexible inputs as their `ClientWithTransactionSending` counterparts, but hand back the signed transactions instead of submitting them. The interface is parameterised over the context attached to its results and makes no default guarantees about that context: what it contains is entirely decided by the plugin providing the capability — typically a `context.transaction` on successful results.

`ClientWithTransactionSending` now also accepts an optional `TContext` type parameter that flows through to the results of `sendTransaction` and `sendTransactions`. Unlike the signing interface, it defaults to `TransactionPlanResultContextWithSignature` for backward compatibility, so existing usage keeps the required `context.signature` on successful results.
2 changes: 1 addition & 1 deletion docs/content/docs/plugins/creating-custom-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The most common interfaces are:
- `ClientWithPayer` and `ClientWithIdentity` for signer roles.
- `ClientWithRpc<TApi>` and `ClientWithRpcSubscriptions<TApi>` for transports.
- `ClientWithAirdrop` and `ClientWithGetMinimumBalance` for funding helpers.
- `ClientWithTransactionPlanning` and `ClientWithTransactionSending` for the transaction lifecycle.
- `ClientWithTransactionPlanning`, `ClientWithTransactionSending` and `ClientWithTransactionSigning` for the transaction lifecycle.

If your plugin's capability does not match any existing interface, define your own type and export it. If you think the capability is generally useful and there is a gap in the standard interfaces, consider opening a PR against [`anza-xyz/kit`](https://github.com/anza-xyz/kit) so other plugins can adopt it too. Treating capabilities as named interfaces makes them addressable from other code without naming the specific plugin that installed them.

Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/plugins/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Kit ships a small set of standard interfaces in `@solana/plugin-interfaces` (re-
- `ClientWithGetMinimumBalance` — installs `client.getMinimumBalance`.
- `ClientWithTransactionPlanning` — installs `client.planTransaction(s)`.
- `ClientWithTransactionSending` — installs `client.sendTransaction(s)`.
- `ClientWithTransactionSigning` — installs `client.signTransaction(s)`.

Sticking to these interfaces lets plugins from any source compose freely with each other — and lets your application code take a `ClientWithRpc` rather than caring which specific plugin produced the RPC capability.

Expand Down
28 changes: 28 additions & 0 deletions packages/plugin-interfaces/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ function transactionCounterPlugin() {

Represents a client that can send transactions to the Solana network. It supports flexible input formats including instructions, instruction plans, transaction messages, or transaction plans.

The interface accepts an optional `TContext` type parameter describing the context attached to the results. It defaults to `TransactionPlanResultContextWithSignature`, so a successful result guarantees a `context.signature` unless a different context is supplied.

```ts
import { extendClient } from '@solana/plugin-core';
import { ClientWithPayer, ClientWithTransactionSending } from '@solana/plugin-interfaces';
Expand All @@ -243,6 +245,32 @@ function transferPlugin() {
}
```

### `ClientWithTransactionSigning`

Represents a client that can sign transactions without sending them. It accepts the same flexible inputs as `ClientWithTransactionSending`, but hands back the signed transactions instead of submitting them — useful when another party, such as a relayer, will pay the fee and broadcast them.

Unlike `ClientWithTransactionSending`, this interface has no default result context: what a signing result's context contains is entirely decided by the plugin providing the capability. Parameterise the interface with the context your code needs — typically one that guarantees `context.transaction` on successful results.

```ts
import { extendClient } from '@solana/plugin-core';
import { ClientWithIdentity, ClientWithTransactionSigning } from '@solana/plugin-interfaces';

function relayedTransferPlugin(relayerUrl: string) {
return <T extends ClientWithIdentity & ClientWithTransactionSigning<{ transaction: Transaction }>>(client: T) =>
extendClient(client, {
transferViaRelayer: async (recipient: Address, amount: Lamports) => {
const instruction = getTransferSolInstruction({
source: client.identity,
destination: recipient,
amount,
});
const result = await client.signTransaction(instruction);
return await postToRelayer(relayerUrl, result.context.transaction);
},
});
}
```

## Combining Interfaces

Use TypeScript intersection types to require multiple capabilities from the client:
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-interfaces/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"@solana/rpc-spec": "workspace:*",
"@solana/rpc-subscriptions-spec": "workspace:*",
"@solana/rpc-types": "workspace:*",
"@solana/signers": "workspace:*"
"@solana/signers": "workspace:*",
"@solana/transactions": "workspace:*"
},
"peerDependencies": {
"typescript": ">=5.4.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import type {
TransactionPlan,
TransactionPlanInput,
TransactionPlanResult,
TransactionPlanResultContext,
TransactionPlanResultContextWithSignature,
} from '@solana/instruction-plans';
import type { Signature } from '@solana/keys';
import type { Transaction } from '@solana/transactions';

import type { ClientWithTransactionPlanning, ClientWithTransactionSending } from '../instruction-plans';
import type {
ClientWithTransactionPlanning,
ClientWithTransactionSending,
ClientWithTransactionSigning,
} from '../instruction-plans';

// [DESCRIBE] ClientWithTransactionPlanning.
{
Expand Down Expand Up @@ -88,18 +96,162 @@ import type { ClientWithTransactionPlanning, ClientWithTransactionSending } from
abortSignal: abortController.signal,
}) satisfies Promise<TransactionPlanResult>);
}

// The default context guarantees a signature on successful results.
{
type Result = Awaited<ReturnType<ClientWithTransactionSending['sendTransaction']>>;
const result = null as unknown as Result;
result.context.signature satisfies Signature;
}

// A custom context propagates to the results of both methods.
{
type CustomContext = TransactionPlanResultContextWithSignature & { slot: bigint };
const client = null as unknown as ClientWithTransactionSending<CustomContext>;
const input = null as unknown as InstructionPlanInput;
void (client.sendTransaction(input) satisfies Promise<SuccessfulSingleTransactionPlanResult<CustomContext>>);
void (client.sendTransactions(input) satisfies Promise<TransactionPlanResult<CustomContext>>);

const result = null as unknown as Awaited<ReturnType<(typeof client)['sendTransaction']>>;
result.context.slot satisfies bigint;
}

// A client with a richer context satisfies the default interface.
{
type CustomContext = TransactionPlanResultContextWithSignature & { slot: bigint };
const client = null as unknown as ClientWithTransactionSending<CustomContext>;
client satisfies ClientWithTransactionSending;
}

// A client whose context drops the signature does not satisfy the default interface.
{
type CustomContext = TransactionPlanResultContext & { signature?: Signature };
const client = null as unknown as ClientWithTransactionSending<CustomContext>;
// @ts-expect-error The default interface guarantees a signature on successful results.
client satisfies ClientWithTransactionSending;
}
}

// [DESCRIBE] ClientWithTransactionSigning.
{
// signTransaction accepts InstructionPlanInput.
{
const client = null as unknown as ClientWithTransactionSigning;
const input = null as unknown as InstructionPlanInput;
void (client.signTransaction(input) satisfies Promise<
SuccessfulSingleTransactionPlanResult<TransactionPlanResultContext>
>);
}

// signTransaction accepts SingleTransactionPlan.
{
const client = null as unknown as ClientWithTransactionSigning;
const plan = null as unknown as SingleTransactionPlan;
void (client.signTransaction(plan) satisfies Promise<
SuccessfulSingleTransactionPlanResult<TransactionPlanResultContext>
>);
}

// signTransaction accepts SingleTransactionPlan['message'].
{
const client = null as unknown as ClientWithTransactionSigning;
const message = null as unknown as SingleTransactionPlan['message'];
void (client.signTransaction(message) satisfies Promise<
SuccessfulSingleTransactionPlanResult<TransactionPlanResultContext>
>);
}

// signTransactions accepts InstructionPlanInput.
{
const client = null as unknown as ClientWithTransactionSigning;
const input = null as unknown as InstructionPlanInput;
void (client.signTransactions(input) satisfies Promise<TransactionPlanResult<TransactionPlanResultContext>>);
}

// signTransactions accepts TransactionPlanInput.
{
const client = null as unknown as ClientWithTransactionSigning;
const input = null as unknown as TransactionPlanInput;
void (client.signTransactions(input) satisfies Promise<TransactionPlanResult<TransactionPlanResultContext>>);
}

// Both methods accept an optional config with abortSignal.
{
const client = null as unknown as ClientWithTransactionSigning;
const input = null as unknown as InstructionPlanInput;
const abortController = new AbortController();
void (client.signTransaction(input, {
abortSignal: abortController.signal,
}) satisfies Promise<SuccessfulSingleTransactionPlanResult<TransactionPlanResultContext>>);
void (client.signTransactions(input, {
abortSignal: abortController.signal,
}) satisfies Promise<TransactionPlanResult<TransactionPlanResultContext>>);
}

// The default context makes no guarantees about its contents.
{
type Result = Awaited<ReturnType<ClientWithTransactionSigning['signTransaction']>>;
const result = null as unknown as Result;
result.context.transaction satisfies unknown;
// @ts-expect-error The bare interface does not guarantee a transaction.
result.context.transaction satisfies Transaction;
// @ts-expect-error The bare interface does not guarantee a signature.
result.context.signature satisfies Signature;
}

// A custom context propagates to the results of both methods.
{
type CustomContext = { transaction: Transaction };
const client = null as unknown as ClientWithTransactionSigning<CustomContext>;
const input = null as unknown as InstructionPlanInput;
void (client.signTransaction(input) satisfies Promise<SuccessfulSingleTransactionPlanResult<CustomContext>>);
void (client.signTransactions(input) satisfies Promise<TransactionPlanResult<CustomContext>>);

const result = null as unknown as Awaited<ReturnType<(typeof client)['signTransaction']>>;
result.context.transaction satisfies Transaction;
}

// A client with a richer context satisfies the bare interface.
{
type CustomContext = { transaction: Transaction };
const client = null as unknown as ClientWithTransactionSigning<CustomContext>;
client satisfies ClientWithTransactionSigning;
}

// A ClientWithTransactionSending's methods satisfy the bare signing methods, since the bare
// signing interface makes no guarantees about the result context.
{
const sendingClient = null as unknown as ClientWithTransactionSending;
sendingClient.sendTransaction satisfies ClientWithTransactionSigning['signTransaction'];
sendingClient.sendTransactions satisfies ClientWithTransactionSigning['signTransactions'];
}

// A ClientWithTransactionSending's methods do not satisfy a signing interface whose context
// guarantees the transaction is retained, since sending results type it as optional.
{
const sendingClient = null as unknown as ClientWithTransactionSending;
type SigningClient = ClientWithTransactionSigning<{ transaction: Transaction }>;
// @ts-expect-error The transaction is not guaranteed on a sending result.
sendingClient.sendTransaction satisfies SigningClient['signTransaction'];
// @ts-expect-error The transaction is not guaranteed on a sending result.
sendingClient.sendTransactions satisfies SigningClient['signTransactions'];
}
}

// [DESCRIBE] Combining ClientWithTransactionPlanning and ClientWithTransactionSending.
// [DESCRIBE] Combining ClientWithTransactionPlanning, ClientWithTransactionSending and ClientWithTransactionSigning.
{
// They can be combined into a single client type.
{
type FullTransactionClient = ClientWithTransactionPlanning & ClientWithTransactionSending;
type FullTransactionClient = ClientWithTransactionPlanning &
ClientWithTransactionSending &
ClientWithTransactionSigning;
const client = null as unknown as FullTransactionClient;

client.planTransaction satisfies ClientWithTransactionPlanning['planTransaction'];
client.planTransactions satisfies ClientWithTransactionPlanning['planTransactions'];
client.sendTransaction satisfies ClientWithTransactionSending['sendTransaction'];
client.sendTransactions satisfies ClientWithTransactionSending['sendTransactions'];
client.signTransaction satisfies ClientWithTransactionSigning['signTransaction'];
client.signTransactions satisfies ClientWithTransactionSigning['signTransactions'];
}
}
87 changes: 84 additions & 3 deletions packages/plugin-interfaces/src/instruction-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
TransactionPlan,
TransactionPlanInput,
TransactionPlanResult,
TransactionPlanResultContext,
TransactionPlanResultContextWithSignature,
} from '@solana/instruction-plans';

type Config = { abortSignal?: AbortSignal };
Expand Down Expand Up @@ -65,6 +67,11 @@ export type ClientWithTransactionPlanning = {
* transactions. It supports flexible input formats including instructions,
* instruction plans, transaction messages or transaction plans.
*
* @typeParam TContext - The context attached to the results. It defaults to
* {@link TransactionPlanResultContextWithSignature}, which guarantees a `context.signature` on
* every successful result. Supply a different context to change or drop that guarantee — for
* instance, a client whose executor records extra fields on the context.
*
* @example
* ```ts
* async function executeTransfer(client: ClientWithTransactionSending) {
Expand All @@ -79,7 +86,9 @@ export type ClientWithTransactionPlanning = {
* }
* ```
*/
export type ClientWithTransactionSending = {
export type ClientWithTransactionSending<
TContext extends TransactionPlanResultContext = TransactionPlanResultContextWithSignature,
> = {
/**
* Sends a single transaction to the network.
*
Expand All @@ -96,7 +105,7 @@ export type ClientWithTransactionSending = {
sendTransaction: (
input: InstructionPlanInput | SingleTransactionPlan | SingleTransactionPlan['message'],
config?: Config,
) => Promise<SuccessfulSingleTransactionPlanResult>;
) => Promise<SuccessfulSingleTransactionPlanResult<TContext>>;

/**
* Sends one or more transactions to the network.
Expand All @@ -114,5 +123,77 @@ export type ClientWithTransactionSending = {
sendTransactions: (
input: InstructionPlanInput | TransactionPlanInput,
config?: Config,
) => Promise<TransactionPlanResult>;
) => Promise<TransactionPlanResult<TContext>>;
};

/**
* Represents a client that can sign transactions without submitting them to the network.
*
* Transaction signing accepts the same flexible inputs as
* {@link ClientWithTransactionSending} — instructions, instruction plans, transaction messages or
* transaction plans — but stops short of sending the resulting transactions. Use it to hand
* transactions off to another party, such as an authority wallet signing a transaction that a
* relayer will pay for and submit later.
*
* @typeParam TContext - The context attached to the results. The interface makes no claim about
* what that context contains: it is entirely decided by the plugin providing the capability, which
* would typically guarantee a `context.transaction` on successful results. Note that this differs
* from {@link ClientWithTransactionSending}, whose default context preserves the
* `context.signature` guarantee that predates configurable contexts.
*
* @example
* ```ts
* async function signTransfer(client: ClientWithTransactionSigning<{ transaction: Transaction }>) {
* const instructions = [createTransferInstruction(...)];
*
* // Sign a single transaction
* const result = await client.signTransaction(instructions);
* const transaction = result.context.transaction;
*
* // Or sign potentially multiple transactions
* const results = await client.signTransactions(instructions);
* }
* ```
*
* @see {@link ClientWithTransactionSending}
*/
export type ClientWithTransactionSigning<TContext extends TransactionPlanResultContext = TransactionPlanResultContext> =
{
/**
* Signs a single transaction without sending it.
*
* Accepts flexible input: instructions, instruction plans, a single
* transaction message or a single transaction plan.
*
* @param input - Instructions, a transaction plan, or a transaction message.
* @param config - Optional configuration including an abort signal.
* @returns A promise resolving to the successful transaction result, carrying the
* `TContext` the client was parameterised with.
*
* @see {@link InstructionPlanInput}
* @see {@link SingleTransactionPlan}
*/
signTransaction: (
input: InstructionPlanInput | SingleTransactionPlan | SingleTransactionPlan['message'],
config?: Config,
) => Promise<SuccessfulSingleTransactionPlanResult<TContext>>;

/**
* Signs one or more transactions without sending them.
*
* Accepts flexible input: instructions, instruction plans, transaction messages
* or transaction plans.
*
* @param input - Any instruction or a transaction plan input.
* @param config - Optional configuration including an abort signal.
* @returns A promise resolving to the results for all transactions. Successful leaves carry
* the `TContext` the client was parameterised with.
*
* @see {@link InstructionPlanInput}
* @see {@link TransactionPlanInput}
*/
signTransactions: (
input: InstructionPlanInput | TransactionPlanInput,
config?: Config,
) => Promise<TransactionPlanResult<TContext>>;
};
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading