Skip to content

Commit b90c711

Browse files
feat: partial signing with sendTransaction() with kit (#68)
* feat: added solana/kit + adjusted delegate instruction * chore: fixed versioning conflict * fix: linter & CI * refactor: lint fix * fix: workflow automation with kit and web3js * fix: workflow automation with kit and web3js * Release/v0.3.6 (#66) * feat: added solana/kit + adjusted delegate instruction * chore: fixed versioning conflict * fix: linter & CI * refactor: lint fix * fix: workflow automation with kit and web3js * fix: workflow automation with kit and web3js * release: 0.3.6 * release: 0.3.6 --------- Co-authored-by: Gabriele Picco <[email protected]> * docs: updated links and naming * feat: partial signing for sendTransaction with kit --------- Co-authored-by: Gabriele Picco <[email protected]>
1 parent b78f6bc commit b90c711

File tree

2 files changed

+137
-55
lines changed

2 files changed

+137
-55
lines changed

ts/kit/src/__test__/connection.test.ts

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ vi.mock("@solana/kit", () => ({
8585
partiallySignTransaction: vi.fn(),
8686
compileTransaction: vi.fn(),
8787
getBase64EncodedWireTransaction: vi.fn(),
88-
pipe: vi.fn((tx, fn) => fn(tx)),
88+
isFullySignedTransaction: vi.fn(),
89+
assertIsTransactionWithBlockhashLifetime: vi.fn(),
8990
assertIsTransactionMessageWithBlockhashLifetime: vi.fn(),
91+
pipe: vi.fn((tx, fn) => fn(tx)),
9092
setTransactionMessageLifetimeUsingBlockhash: vi.fn((blockhash, tx) => tx),
9193
}));
9294

@@ -140,6 +142,15 @@ describe("Connection", () => {
140142
mockSignature,
141143
);
142144
vi.mocked(utils.parseCommitsLogsMessage).mockReturnValue(mockSignature);
145+
vi.mocked(solanaKit.isFullySignedTransaction).mockImplementation(
146+
() => true,
147+
);
148+
vi.mocked(
149+
solanaKit.assertIsTransactionWithBlockhashLifetime,
150+
).mockImplementation(() => {});
151+
vi.mocked(
152+
solanaKit.assertIsTransactionMessageWithBlockhashLifetime,
153+
).mockImplementation(() => {});
143154
});
144155

145156
it("should create a Connection instance", async () => {
@@ -177,6 +188,72 @@ describe("Connection", () => {
177188
expect(sig).toBe(mockSignature);
178189
});
179190

191+
it("sendTransaction - fully signed TransactionWithLifetime", async () => {
192+
const connection = await Connection.create("http://localhost");
193+
194+
const fullySignedTx: solanaKit.Transaction &
195+
solanaKit.TransactionWithLifetime = {
196+
messageBytes: mockTxBytes,
197+
signatures: {},
198+
lifetimeConstraint: mockLifetimeConstraint,
199+
};
200+
201+
vi.mocked(solanaKit.isFullySignedTransaction).mockImplementation(
202+
() => true,
203+
);
204+
205+
const sig = await connection.sendTransaction(fullySignedTx, mockSigners);
206+
expect(sig).toBe(mockSignature);
207+
expect(solanaKit.isFullySignedTransaction).toHaveBeenCalled();
208+
});
209+
210+
it("sendTransaction - unsigned TransactionWithLifetime", async () => {
211+
const connection = await Connection.create("http://localhost");
212+
213+
const unsignedTx: solanaKit.Transaction &
214+
solanaKit.TransactionWithLifetime = {
215+
messageBytes: mockTxBytes,
216+
signatures: {},
217+
lifetimeConstraint: mockLifetimeConstraint,
218+
};
219+
220+
vi.mocked(solanaKit.partiallySignTransaction).mockResolvedValue({
221+
messageBytes: mockTxBytes,
222+
signatures: {},
223+
lifetimeConstraint: mockLifetimeConstraint,
224+
});
225+
226+
const sig = await connection.sendTransaction(unsignedTx, mockSigners);
227+
228+
expect(sig).toBe(mockSignature);
229+
expect(solanaKit.isFullySignedTransaction).toHaveBeenCalled();
230+
});
231+
232+
it("sendTransaction - TransactionMessage without blockhash", async () => {
233+
const connection = await Connection.create("http://localhost");
234+
235+
const txMessage: solanaKit.TransactionMessage &
236+
solanaKit.TransactionMessageWithFeePayer<string> = {
237+
feePayer: "payer",
238+
} as any;
239+
240+
const prepareSpy = vi
241+
.spyOn(connection, "prepareTransactionWithLatestBlockhash")
242+
.mockImplementation(async (tx) => ({
243+
...tx,
244+
messageBytes: mockTxBytes,
245+
signatures: {},
246+
lifetimeConstraint: mockLifetimeConstraint,
247+
}));
248+
249+
const sig = await connection.sendTransaction(txMessage, mockSigners);
250+
251+
expect(sig).toBe(mockSignature);
252+
expect(prepareSpy).toHaveBeenCalledWith(txMessage);
253+
expect(solanaKit.compileTransaction).toHaveBeenCalled();
254+
expect(solanaKit.partiallySignTransaction).toHaveBeenCalled();
255+
});
256+
180257
it("should confirmTransaction without errors", async () => {
181258
const connection = await Connection.create("http://localhost");
182259
await expect(
@@ -189,11 +266,12 @@ describe("Connection", () => {
189266

190267
it("should partiallySignTransaction", async () => {
191268
const connection = await Connection.create("http://localhost");
192-
const txMessage = {
193-
feePayer: "payer",
194-
lifetimeConstraint: { blockhash: "bh" },
195-
} as unknown as solanaKit.TransactionMessage &
196-
solanaKit.TransactionMessageWithFeePayer<string>;
269+
const txMessage: solanaKit.Transaction & solanaKit.TransactionWithLifetime =
270+
{
271+
messageBytes: mockTxBytes,
272+
signatures: {},
273+
lifetimeConstraint: mockLifetimeConstraint,
274+
};
197275
const partiallySigned = await connection.partiallySignTransaction(
198276
mockSigners,
199277
txMessage,

ts/kit/src/connection.ts

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import {
1717
setTransactionMessageLifetimeUsingBlockhash,
1818
SolanaRpcApi,
1919
TransactionWithBlockhashLifetime,
20-
TransactionMessageBytes,
21-
SignaturesMap,
20+
isFullySignedTransaction,
21+
Transaction,
22+
TransactionWithLifetime,
23+
assertIsTransactionWithBlockhashLifetime,
2224
} from "@solana/kit";
2325

2426
import {
@@ -169,9 +171,9 @@ export class Connection {
169171
* Sends a transaction message to the network.
170172
*
171173
* This method:
172-
* 1. Fetches the latest blockhash for the writable accounts.
173-
* 2. Sets it as the transaction’s lifetime.
174-
* 3. Signs and serializes the transaction.
174+
* 1. Fetches the latest blockhash for the writable accounts. (optional)
175+
* 2. Sets it as the transaction’s lifetime. (optional)
176+
* 3. Signs and serializes the transaction. (optional)
175177
* 4. Sends it to the cluster.
176178
*
177179
* @param transaction - The transaction message to send.
@@ -181,19 +183,15 @@ export class Connection {
181183
*/
182184
public async sendTransaction(
183185
transaction:
184-
| (TransactionMessage & TransactionMessageWithFeePayer)
185-
| Readonly<{
186-
messageBytes: TransactionMessageBytes;
187-
signatures: SignaturesMap;
188-
}>,
186+
| Transaction
187+
| (Transaction & TransactionWithBlockhashLifetime)
188+
| (TransactionMessage & TransactionMessageWithFeePayer),
189189
signers: CryptoKeyPair[],
190-
options?: {
191-
skipPreflight?: boolean;
192-
preflightCommitment?: Commitment;
193-
},
190+
options?: { skipPreflight?: boolean; preflightCommitment?: Commitment },
194191
): Promise<Signature> {
195192
const { skipPreflight = true, preflightCommitment = "confirmed" } =
196193
options ?? {};
194+
197195
const hasBlockhash =
198196
"lifetimeConstraint" in transaction &&
199197
typeof transaction.lifetimeConstraint === "object" &&
@@ -202,39 +200,55 @@ export class Connection {
202200
(transaction.lifetimeConstraint as { blockhash?: unknown }).blockhash !==
203201
undefined;
204202

205-
if ("signatures" in transaction && "messageBytes" in transaction) {
206-
const serializedTransaction =
207-
getBase64EncodedWireTransaction(transaction);
208-
const signature = await this.rpc
209-
.sendTransaction(serializedTransaction, {
203+
const serializeAndSendTransaction = async (tx: Transaction) => {
204+
const serialized = getBase64EncodedWireTransaction(tx);
205+
return this.rpc
206+
.sendTransaction(serialized, {
210207
encoding: "base64",
211208
skipPreflight,
212209
preflightCommitment,
213210
...options,
214211
})
215212
.send();
216-
return signature;
217-
} else {
218-
if (!hasBlockhash) {
219-
transaction =
220-
await this.prepareTransactionWithLatestBlockhash(transaction);
213+
};
214+
215+
// Case 1: Already serialized form
216+
if ("signatures" in transaction && "messageBytes" in transaction) {
217+
const fullySigned = (() => {
218+
try {
219+
isFullySignedTransaction(transaction);
220+
return true;
221+
} catch {
222+
return false;
223+
}
224+
})();
225+
226+
if (fullySigned) {
227+
return serializeAndSendTransaction(transaction);
221228
}
229+
230+
assertIsTransactionWithBlockhashLifetime(transaction);
222231
const signedTransaction = await this.partiallySignTransaction(
223232
signers,
224233
transaction,
225234
);
226-
const serializedTransaction =
227-
getBase64EncodedWireTransaction(signedTransaction);
228-
const signature = await this.rpc
229-
.sendTransaction(serializedTransaction, {
230-
encoding: "base64",
231-
skipPreflight,
232-
preflightCommitment,
233-
...options,
234-
})
235-
.send();
236-
return signature;
235+
isFullySignedTransaction(signedTransaction);
236+
return serializeAndSendTransaction(signedTransaction);
237+
}
238+
239+
// Case 2: TransactionMessage form
240+
if (!hasBlockhash) {
241+
transaction =
242+
await this.prepareTransactionWithLatestBlockhash(transaction);
237243
}
244+
245+
assertIsTransactionMessageWithBlockhashLifetime(transaction);
246+
const compiled = compileTransaction(transaction);
247+
const signedTransaction = await this.partiallySignTransaction(
248+
signers,
249+
compiled,
250+
);
251+
return serializeAndSendTransaction(signedTransaction);
238252
}
239253

240254
/**
@@ -264,21 +278,11 @@ export class Connection {
264278
*/
265279
public async partiallySignTransaction(
266280
signers: CryptoKeyPair[],
267-
transaction: TransactionMessage & TransactionMessageWithFeePayer<string>,
268-
): Promise<
269-
Readonly<
270-
TransactionWithBlockhashLifetime &
271-
Readonly<{
272-
messageBytes: TransactionMessageBytes;
273-
signatures: SignaturesMap;
274-
}>
275-
>
276-
> {
277-
assertIsTransactionMessageWithBlockhashLifetime(transaction);
278-
const compiledTransaction = compileTransaction(transaction);
281+
transaction: Transaction & TransactionWithLifetime,
282+
): Promise<Transaction & TransactionWithLifetime> {
279283
const signedTransaction = await partiallySignTransaction(
280284
signers,
281-
compiledTransaction,
285+
transaction,
282286
);
283287
return signedTransaction;
284288
}
@@ -310,7 +314,7 @@ export class Connection {
310314
* @returns The latest blockhash and last valid block height.
311315
*/
312316
public async getLatestBlockhashForTransaction(
313-
transaction: TransactionMessage,
317+
transaction: TransactionMessage & TransactionMessageWithFeePayer,
314318
): Promise<Readonly<LatestBlockhash>> {
315319
const writableAccounts = getWritableAccounts(transaction);
316320

0 commit comments

Comments
 (0)