Skip to content

Commit c87cada

Browse files
authored
Update useWalletAccountTransactionSigner to return a LifetimeConstraint for the updated transaction (#919)
#### Problem Currently this signer only returns what is required for the signer interface, a `Transaction`. However, per #891 this interface needs to be changed to return a `Transaction & TransactionWithLifetime`, ie augmented with a `lifetimeConstraint` field. #### Summary of Changes The signer now returns a lifetime, by decoding the returned `messageBytes` and comparing the returned `lifetimeToken` with the existing lifetime of the input transaction. - If the input transaction does not have a lifetime, then we create one for the signed transaction using #918. Otherwise: - If the input transaction and signed transaction have identical message bytes, then we return the existing lifetime. Ie if the wallet returns the transaction unchanged. - If the message bytes differ, but the `lifetimeToken` of the signed transaction matches the existing lifetime (either blockhash or nonce field), then we return the existing lifetime. Ie if the wallet modifies the transaction but not its lifetime. - If the lifetime token of the signed transaction differs from that of the input transaction, then we create a new lifetime for the signed transaction using #918. This pre-emptively makes `useWalletAccountTransactionSigner` comply with a stricter interface for `TransactionModifyingSigner` that will require returning `Transaction & TransactionWithLifetime`.
1 parent 5408f52 commit c87cada

File tree

5 files changed

+223
-8
lines changed

5 files changed

+223
-8
lines changed

.changeset/sharp-falcons-end.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@solana/errors': patch
3+
'@solana/react': patch
4+
---
5+
6+
Update useWalletAccountTransactionSigner to return a LifetimeConstraint for the updated transaction

packages/react/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"@solana/keys": "workspace:*",
7979
"@solana/promises": "workspace:*",
8080
"@solana/signers": "workspace:*",
81+
"@solana/transaction-messages": "workspace:*",
8182
"@solana/transactions": "workspace:*",
8283
"@solana/wallet-standard-features": "^1.3.0",
8384
"@wallet-standard/base": "^1.1.0",
@@ -87,6 +88,7 @@
8788
},
8889
"devDependencies": {
8990
"@solana/codecs-core": "workspace:*",
91+
"@solana/rpc-types": "workspace:*",
9092
"@types/react": "^19",
9193
"@types/react-test-renderer": "^19",
9294
"react": "^19",

packages/react/src/__tests__/useWalletAccountTransactionSigner-test.ts

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
11
import { Address } from '@solana/addresses';
2-
import type { VariableSizeCodec } from '@solana/codecs-core';
3-
import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors';
2+
import type { VariableSizeCodec, VariableSizeDecoder } from '@solana/codecs-core';
3+
import {
4+
SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED,
5+
SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE,
6+
SolanaError,
7+
} from '@solana/errors';
48
import { SignatureBytes } from '@solana/keys';
5-
import { Transaction, TransactionMessageBytes } from '@solana/transactions';
9+
import { Blockhash } from '@solana/rpc-types';
10+
import {
11+
CompiledTransactionMessage,
12+
CompiledTransactionMessageWithLifetime,
13+
getCompiledTransactionMessageDecoder,
14+
} from '@solana/transaction-messages';
15+
import {
16+
getTransactionLifetimeConstraintFromCompiledTransactionMessage,
17+
Transaction,
18+
TransactionMessageBytes,
19+
} from '@solana/transactions';
620
import { getTransactionCodec } from '@solana/transactions';
721
import type { UiWalletAccount } from '@wallet-standard/ui';
822

923
import { renderHook } from '../test-renderer';
1024
import { useSignTransaction } from '../useSignTransaction';
1125
import { useWalletAccountTransactionSigner } from '../useWalletAccountTransactionSigner';
1226

27+
jest.mock('@solana/transaction-messages');
1328
jest.mock('@solana/transactions');
1429
jest.mock('../useSignTransaction');
1530

@@ -120,8 +135,8 @@ describe('useWalletAccountTransactionSigner', () => {
120135
});
121136
it('decodes and returns the signed transaction bytes returned by `signTransactions`', async () => {
122137
expect.assertions(2);
123-
const mockSignedTransaction = new Uint8Array([1, 2, 3]);
124-
const mockDecodedTransaction = {} as Transaction;
138+
const mockSignedTransaction = new Uint8Array([1, 2, 3, 4, 5, 6]);
139+
const mockDecodedTransaction = { messageBytes: [1, 2, 3] } as unknown as Transaction;
125140
mockSignTransaction.mockResolvedValue({ signedTransaction: mockSignedTransaction });
126141
mockDecodeTransaction.mockReturnValue(mockDecodedTransaction);
127142
const { result } = renderHook(() => useWalletAccountTransactionSigner(mockUiWalletAccount, 'solana:danknet'));
@@ -130,7 +145,9 @@ describe('useWalletAccountTransactionSigner', () => {
130145
throw result.current;
131146
} else {
132147
const { modifyAndSignTransactions } = result.current;
148+
const lifetimeConstraint = { blockhash: 'abc', lastValidBlockHeight: 123n };
133149
const inputTransaction = {
150+
lifetimeConstraint,
134151
messageBytes: new Uint8Array([1, 2, 3]) as unknown as TransactionMessageBytes,
135152
signatures: {
136153
'11111111111111111111111111111114': new Uint8Array(64).fill(2) as SignatureBytes,
@@ -141,7 +158,7 @@ describe('useWalletAccountTransactionSigner', () => {
141158
// eslint-disable-next-line jest/no-conditional-expect
142159
expect(mockDecodeTransaction).toHaveBeenCalledWith(mockSignedTransaction);
143160
// eslint-disable-next-line jest/no-conditional-expect
144-
await expect(signPromise).resolves.toEqual([mockDecodedTransaction]);
161+
await expect(signPromise).resolves.toEqual([{ ...mockDecodedTransaction, lifetimeConstraint }]);
145162
}
146163
});
147164
it('calls `signTransaction` with all options except the `abortSignal`', () => {
@@ -193,4 +210,133 @@ describe('useWalletAccountTransactionSigner', () => {
193210
await expect(signPromise).rejects.toThrow(new Error('o no'));
194211
}
195212
});
213+
it('returns an unchanged lifetime constraint if the signed transaction has the same one', async () => {
214+
expect.assertions(1);
215+
const mockSignedTransaction = new Uint8Array([1, 2, 3, 4, 5, 6]);
216+
const mockDecodedTransaction = { messageBytes: [4, 5, 6] } as unknown as Transaction;
217+
mockSignTransaction.mockResolvedValue({ signedTransaction: mockSignedTransaction });
218+
mockDecodeTransaction.mockReturnValue(mockDecodedTransaction);
219+
const { result } = renderHook(() => useWalletAccountTransactionSigner(mockUiWalletAccount, 'solana:danknet'));
220+
// eslint-disable-next-line jest/no-conditional-in-test
221+
if (result.__type === 'error' || !result.current) {
222+
throw result.current;
223+
} else {
224+
const { modifyAndSignTransactions } = result.current;
225+
const lifetimeConstraint = { blockhash: 'abc', lastValidBlockHeight: 123n };
226+
const inputTransaction = {
227+
lifetimeConstraint,
228+
messageBytes: new Uint8Array([1, 2, 3]) as unknown as TransactionMessageBytes,
229+
signatures: {},
230+
};
231+
jest.mocked(getCompiledTransactionMessageDecoder).mockReturnValue({
232+
decode: jest.fn().mockReturnValue({
233+
lifetimeToken: 'abc',
234+
}),
235+
} as unknown as VariableSizeDecoder<CompiledTransactionMessage & CompiledTransactionMessageWithLifetime>);
236+
const signPromise = modifyAndSignTransactions([inputTransaction]);
237+
await jest.runAllTimersAsync();
238+
// eslint-disable-next-line jest/no-conditional-expect
239+
await expect(signPromise).resolves.toEqual([{ ...mockDecodedTransaction, lifetimeConstraint }]);
240+
}
241+
});
242+
it('returns a new lifetime constraint if the input transaction does not have one', async () => {
243+
expect.assertions(1);
244+
const mockSignedTransaction = new Uint8Array([1, 2, 3, 4, 5, 6]);
245+
const mockDecodedTransaction = { messageBytes: [4, 5, 6] } as unknown as Transaction;
246+
mockSignTransaction.mockResolvedValue({ signedTransaction: mockSignedTransaction });
247+
mockDecodeTransaction.mockReturnValue(mockDecodedTransaction);
248+
const { result } = renderHook(() => useWalletAccountTransactionSigner(mockUiWalletAccount, 'solana:danknet'));
249+
// eslint-disable-next-line jest/no-conditional-in-test
250+
if (result.__type === 'error' || !result.current) {
251+
throw result.current;
252+
} else {
253+
const { modifyAndSignTransactions } = result.current;
254+
const inputTransaction = {
255+
messageBytes: new Uint8Array([1, 2, 3]) as unknown as TransactionMessageBytes,
256+
signatures: {},
257+
};
258+
jest.mocked(getCompiledTransactionMessageDecoder).mockReturnValue({
259+
decode: jest.fn().mockReturnValue({}),
260+
} as unknown as VariableSizeDecoder<CompiledTransactionMessage & CompiledTransactionMessageWithLifetime>);
261+
const newLifetimeConstraint = { blockhash: 'abc' as Blockhash, lastValidBlockHeight: 123n };
262+
jest.mocked(getTransactionLifetimeConstraintFromCompiledTransactionMessage).mockResolvedValue(
263+
newLifetimeConstraint,
264+
);
265+
const signPromise = modifyAndSignTransactions([inputTransaction]);
266+
await jest.runAllTimersAsync();
267+
// eslint-disable-next-line jest/no-conditional-expect
268+
await expect(signPromise).resolves.toEqual([
269+
{ ...mockDecodedTransaction, lifetimeConstraint: newLifetimeConstraint },
270+
]);
271+
}
272+
});
273+
it('returns a new lifetime constraint if the signed transaction has a different one', async () => {
274+
expect.assertions(1);
275+
const mockSignedTransaction = new Uint8Array([1, 2, 3, 4, 5, 6]);
276+
const mockDecodedTransaction = { messageBytes: [4, 5, 6] } as unknown as Transaction;
277+
mockSignTransaction.mockResolvedValue({ signedTransaction: mockSignedTransaction });
278+
mockDecodeTransaction.mockReturnValue(mockDecodedTransaction);
279+
const { result } = renderHook(() => useWalletAccountTransactionSigner(mockUiWalletAccount, 'solana:danknet'));
280+
// eslint-disable-next-line jest/no-conditional-in-test
281+
if (result.__type === 'error' || !result.current) {
282+
throw result.current;
283+
} else {
284+
const { modifyAndSignTransactions } = result.current;
285+
const inputLifetimeConstraint = { blockhash: 'abc', lastValidBlockHeight: 123n };
286+
const inputTransaction = {
287+
lifetimeConstraint: inputLifetimeConstraint,
288+
messageBytes: new Uint8Array([1, 2, 3]) as unknown as TransactionMessageBytes,
289+
signatures: {},
290+
};
291+
jest.mocked(getCompiledTransactionMessageDecoder).mockReturnValue({
292+
decode: jest.fn().mockReturnValue({
293+
lifetimeToken: 'def',
294+
}),
295+
} as unknown as VariableSizeDecoder<CompiledTransactionMessage & CompiledTransactionMessageWithLifetime>);
296+
const newLifetimeConstraint = { blockhash: 'def' as Blockhash, lastValidBlockHeight: 456n };
297+
jest.mocked(getTransactionLifetimeConstraintFromCompiledTransactionMessage).mockResolvedValue(
298+
newLifetimeConstraint,
299+
);
300+
const signPromise = modifyAndSignTransactions([inputTransaction]);
301+
await jest.runAllTimersAsync();
302+
// eslint-disable-next-line jest/no-conditional-expect
303+
await expect(signPromise).resolves.toEqual([
304+
{ ...mockDecodedTransaction, lifetimeConstraint: newLifetimeConstraint },
305+
]);
306+
}
307+
});
308+
it('fatals when the signed transaction has a new durable nonce lifetime but the nonce account is only in a lookup table', async () => {
309+
expect.assertions(1);
310+
const mockSignedTransaction = new Uint8Array([1, 2, 3, 4, 5, 6]);
311+
const mockDecodedTransaction = { messageBytes: [4, 5, 6] } as unknown as Transaction;
312+
mockSignTransaction.mockResolvedValue({ signedTransaction: mockSignedTransaction });
313+
mockDecodeTransaction.mockReturnValue(mockDecodedTransaction);
314+
const { result } = renderHook(() => useWalletAccountTransactionSigner(mockUiWalletAccount, 'solana:danknet'));
315+
// eslint-disable-next-line jest/no-conditional-in-test
316+
if (result.__type === 'error' || !result.current) {
317+
throw result.current;
318+
} else {
319+
const { modifyAndSignTransactions } = result.current;
320+
const inputTransaction = {
321+
messageBytes: new Uint8Array([1, 2, 3]) as unknown as TransactionMessageBytes,
322+
signatures: {},
323+
};
324+
jest.mocked(getCompiledTransactionMessageDecoder).mockReturnValue({
325+
decode: jest.fn().mockReturnValue({}),
326+
} as unknown as VariableSizeDecoder<CompiledTransactionMessage & CompiledTransactionMessageWithLifetime>);
327+
jest.mocked(getTransactionLifetimeConstraintFromCompiledTransactionMessage).mockRejectedValue(
328+
new SolanaError(SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, {
329+
nonce: 'abc',
330+
}),
331+
);
332+
const signPromise = modifyAndSignTransactions([inputTransaction]);
333+
await jest.runAllTimersAsync();
334+
// eslint-disable-next-line jest/no-conditional-expect
335+
await expect(signPromise).rejects.toThrow(
336+
new SolanaError(SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, {
337+
nonce: 'abc',
338+
}),
339+
);
340+
}
341+
});
196342
});

packages/react/src/useWalletAccountTransactionSigner.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { address } from '@solana/addresses';
2+
import { ReadonlyUint8Array } from '@solana/codecs-core';
23
import { SOLANA_ERROR__SIGNER__WALLET_MULTISIGN_UNIMPLEMENTED, SolanaError } from '@solana/errors';
34
import { getAbortablePromise } from '@solana/promises';
45
import { TransactionModifyingSigner } from '@solana/signers';
5-
import { getTransactionCodec } from '@solana/transactions';
6+
import { getCompiledTransactionMessageDecoder } from '@solana/transaction-messages';
7+
import {
8+
getTransactionCodec,
9+
getTransactionLifetimeConstraintFromCompiledTransactionMessage,
10+
TransactionWithLifetime,
11+
} from '@solana/transactions';
612
import { UiWalletAccount } from '@wallet-standard/ui';
713
import { useMemo, useRef } from 'react';
814

@@ -80,9 +86,58 @@ export function useWalletAccountTransactionSigner<TWalletAccount extends UiWalle
8086
const decodedSignedTransaction = transactionCodec.decode(
8187
signedTransaction,
8288
) as (typeof transactions)[number];
83-
return Object.freeze([decodedSignedTransaction]);
89+
90+
const existingLifetime =
91+
'lifetimeConstraint' in transaction
92+
? (transaction as TransactionWithLifetime).lifetimeConstraint
93+
: undefined;
94+
95+
if (existingLifetime) {
96+
if (uint8ArraysEqual(decodedSignedTransaction.messageBytes, transaction.messageBytes)) {
97+
// If the transaction has identical bytes, the lifetime won't have changed
98+
return Object.freeze([
99+
{
100+
...decodedSignedTransaction,
101+
lifetimeConstraint: existingLifetime,
102+
},
103+
]);
104+
}
105+
106+
// If the transaction has changed, check the lifetime constraint field
107+
const compiledTransactionMessage = getCompiledTransactionMessageDecoder().decode(
108+
decodedSignedTransaction.messageBytes,
109+
);
110+
const currentToken =
111+
'blockhash' in existingLifetime ? existingLifetime.blockhash : existingLifetime.nonce;
112+
113+
if (compiledTransactionMessage.lifetimeToken === currentToken) {
114+
return Object.freeze([
115+
{
116+
...decodedSignedTransaction,
117+
lifetimeConstraint: existingLifetime,
118+
},
119+
]);
120+
}
121+
}
122+
123+
// If we get here then there is no existing lifetime, or the lifetime has changed. We need to attach a new lifetime
124+
const compiledTransactionMessage = getCompiledTransactionMessageDecoder().decode(
125+
decodedSignedTransaction.messageBytes,
126+
);
127+
const lifetimeConstraint =
128+
await getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage);
129+
return Object.freeze([
130+
{
131+
...decodedSignedTransaction,
132+
lifetimeConstraint,
133+
},
134+
]);
84135
},
85136
}),
86137
[uiWalletAccount.address, signTransaction],
87138
);
88139
}
140+
141+
function uint8ArraysEqual(arr1: ReadonlyUint8Array, arr2: ReadonlyUint8Array) {
142+
return arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]);
143+
}

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)