Skip to content

Commit a3c5ca9

Browse files
authored
add functions to sign the new TransactionMessage type (#2387)
* add a new ITransactionWithSignatures and IFullySignedTransaction type * remove the OrderedMap class and use native objects * rename new transaction/fully signed transaction types * refactor to sign a NewTransaction and not a TransactionMessage * refactor to always compute a new signature and replace if changed (or null) * throw an error if partialSign is called with a keypair that is not expected to sign the transaction * refactor new-signatures unit tests to use await expect
1 parent 773adcf commit a3c5ca9

File tree

8 files changed

+607
-5
lines changed

8 files changed

+607
-5
lines changed

packages/errors/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ npx @solana/errors decode -- 123
3939

4040
1. Add a new exported error code constant to `src/codes.ts`.
4141
2. Add that new constant to the `SolanaErrorCode` union in `src/codes.ts`.
42-
3. If you would like the new error to encapsulate context about the error itself (eg. the public keys for which a transaction is missing signatures) define the shape of that context in `src/details.ts`.
42+
3. If you would like the new error to encapsulate context about the error itself (eg. the public keys for which a transaction is missing signatures) define the shape of that context in `src/context.ts`.
4343
4. Add the error's message to `src/messages.ts`. Any context values that you defined above will be interpolated into the message wherever you write `$key`, where `key` is the index of a value in the context (eg. ``'Missing a signature for account `$address`'``).
4444
5. Publish a new version of `@solana/errors`.
4545
6. Bump the version of `@solana/errors` in the package from which the error is thrown.

packages/errors/src/codes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export const SOLANA_ERROR__TRANSACTION__FEE_PAYER_SIGNATURE_MISSING = 5663012 as
199199
export const SOLANA_ERROR__TRANSACTION__INVALID_NONCE_TRANSACTION_INSTRUCTIONS_MISSING = 5663013 as const;
200200
export const SOLANA_ERROR__TRANSACTION__INVALID_NONCE_TRANSACTION_FIRST_INSTRUCTION_MUST_BE_ADVANCE_NONCE =
201201
5663014 as const;
202+
export const SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION = 5663015 as const;
202203

203204
// Transaction errors.
204205
// Reserve error codes starting with [7050000-7050999] for the Rust enum `TransactionError`.
@@ -467,6 +468,7 @@ export type SolanaErrorCode =
467468
| typeof SOLANA_ERROR__SUBTLE_CRYPTO__VERIFY_FUNCTION_UNIMPLEMENTED
468469
| typeof SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE
469470
| typeof SOLANA_ERROR__TRANSACTION__ADDRESS_MISSING
471+
| typeof SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION
470472
| typeof SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME
471473
| typeof SOLANA_ERROR__TRANSACTION__EXPECTED_NONCE_LIFETIME
472474
| typeof SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS_MISSING

packages/errors/src/context.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import {
131131
SOLANA_ERROR__SIGNER__EXPECTED_TRANSACTION_SIGNER,
132132
SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE,
133133
SOLANA_ERROR__TRANSACTION__ADDRESS_MISSING,
134+
SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION,
134135
SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS_MISSING,
135136
SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_INDEX_OUT_OF_RANGE,
136137
SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_INSTRUCTION_PROGRAM_ADDRESS_NOT_FOUND,
@@ -523,6 +524,10 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined<
523524
errorName: string;
524525
transactionErrorContext?: unknown;
525526
};
527+
[SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION]: {
528+
expectedAddresses: string[];
529+
unexpectedAddresses: string[];
530+
};
526531
[SOLANA_ERROR__TRANSACTION__ADDRESS_MISSING]: {
527532
index: number;
528533
};

packages/errors/src/messages.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ import {
160160
SOLANA_ERROR__SUBTLE_CRYPTO__VERIFY_FUNCTION_UNIMPLEMENTED,
161161
SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE,
162162
SOLANA_ERROR__TRANSACTION__ADDRESS_MISSING,
163+
SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION,
163164
SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME,
164165
SOLANA_ERROR__TRANSACTION__EXPECTED_NONCE_LIFETIME,
165166
SOLANA_ERROR__TRANSACTION__FAILED_TO_DECOMPILE_ADDRESS_LOOKUP_TABLE_CONTENTS_MISSING,
@@ -542,6 +543,8 @@ export const SolanaErrorMessages: Readonly<{
542543
[SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_BLOCK_COST_LIMIT]:
543544
'Transaction would exceed max Block Cost Limit',
544545
[SOLANA_ERROR__TRANSACTION_ERROR__WOULD_EXCEED_MAX_VOTE_COST_LIMIT]: 'Transaction would exceed max Vote Cost Limit',
546+
[SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION]:
547+
'Attempted to sign a transaction with an address that is not a signer for it',
545548
[SOLANA_ERROR__TRANSACTION__ADDRESS_MISSING]: 'Transaction is missing an address at index: $index.',
546549
[SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME]: 'Transaction does not have a blockhash lifetime',
547550
[SOLANA_ERROR__TRANSACTION__EXPECTED_NONCE_LIFETIME]: 'Transaction is not a durable nonce transaction',

packages/transactions/src/__tests__/new-signatures-test.ts

Lines changed: 474 additions & 0 deletions
Large diffs are not rendered by default.

packages/transactions/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './fee-payer';
77
export * from './instructions';
88
export * from './message';
99
export * from './new-compile-transaction';
10+
export * from './new-signatures';
1011
export * from './serializers';
1112
export * from './signatures';
1213
export * from './types';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Address, getAddressFromPublicKey } from '@solana/addresses';
2+
import { Decoder } from '@solana/codecs-core';
3+
import { getBase58Decoder } from '@solana/codecs-strings';
4+
import {
5+
SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION,
6+
SOLANA_ERROR__TRANSACTION__FEE_PAYER_SIGNATURE_MISSING,
7+
SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING,
8+
SolanaError,
9+
} from '@solana/errors';
10+
import { Signature, SignatureBytes, signBytes } from '@solana/keys';
11+
12+
import { NewTransaction } from './new-compile-transaction';
13+
14+
export interface FullySignedTransaction extends NewTransaction {
15+
readonly __brand: unique symbol;
16+
}
17+
18+
let base58Decoder: Decoder<string> | undefined;
19+
20+
export function newGetSignatureFromTransaction(transaction: NewTransaction): Signature {
21+
if (!base58Decoder) base58Decoder = getBase58Decoder();
22+
23+
// We have ordered signatures from the compiled message accounts
24+
// first signature is the fee payer
25+
const signatureBytes = Object.values(transaction.signatures)[0];
26+
if (!signatureBytes) {
27+
throw new SolanaError(SOLANA_ERROR__TRANSACTION__FEE_PAYER_SIGNATURE_MISSING);
28+
}
29+
const transactionSignature = base58Decoder.decode(signatureBytes);
30+
return transactionSignature as Signature;
31+
}
32+
33+
function uint8ArraysEqual(arr1: Uint8Array, arr2: Uint8Array) {
34+
return arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]);
35+
}
36+
37+
export async function newPartiallySignTransaction(
38+
keyPairs: CryptoKeyPair[],
39+
transaction: NewTransaction,
40+
): Promise<NewTransaction> {
41+
let newSignatures: Record<Address, SignatureBytes> | undefined;
42+
let unexpectedSigners: Set<Address> | undefined;
43+
44+
await Promise.all(
45+
keyPairs.map(async keyPair => {
46+
const address = await getAddressFromPublicKey(keyPair.publicKey);
47+
const existingSignature = transaction.signatures[address];
48+
49+
// Check if the address is expected to sign the transaction
50+
if (existingSignature === undefined) {
51+
// address is not an expected signer for this transaction
52+
unexpectedSigners ||= new Set();
53+
unexpectedSigners.add(address);
54+
return;
55+
}
56+
57+
// Return if there are any unexpected signers already since we won't be using signatures
58+
if (unexpectedSigners) {
59+
return;
60+
}
61+
62+
const newSignature = await signBytes(keyPair.privateKey, transaction.messageBytes);
63+
64+
if (existingSignature !== null && uint8ArraysEqual(newSignature, existingSignature)) {
65+
// already have the same signature set
66+
return;
67+
}
68+
69+
newSignatures ||= {};
70+
newSignatures[address] = newSignature;
71+
}),
72+
);
73+
74+
if (unexpectedSigners && unexpectedSigners.size > 0) {
75+
const expectedSigners = Object.keys(transaction.signatures);
76+
throw new SolanaError(SOLANA_ERROR__TRANSACTION__ADDRESSES_CANNOT_SIGN_TRANSACTION, {
77+
expectedAddresses: expectedSigners,
78+
unexpectedAddresses: [...unexpectedSigners],
79+
});
80+
}
81+
82+
if (!newSignatures) {
83+
return transaction;
84+
}
85+
86+
return Object.freeze({
87+
...transaction,
88+
signatures: Object.freeze({
89+
...transaction.signatures,
90+
...newSignatures,
91+
}),
92+
});
93+
}
94+
95+
export async function newSignTransaction(
96+
keyPairs: CryptoKeyPair[],
97+
transaction: NewTransaction,
98+
): Promise<FullySignedTransaction> {
99+
const out = await newPartiallySignTransaction(keyPairs, transaction);
100+
newAssertTransactionIsFullySigned(out);
101+
Object.freeze(out);
102+
return out;
103+
}
104+
105+
export function newAssertTransactionIsFullySigned(
106+
transaction: NewTransaction,
107+
): asserts transaction is FullySignedTransaction {
108+
const missingSigs: Address[] = [];
109+
Object.entries(transaction.signatures).forEach(([address, signatureBytes]) => {
110+
if (!signatureBytes) {
111+
missingSigs.push(address as Address);
112+
}
113+
});
114+
115+
if (missingSigs.length > 0) {
116+
throw new SolanaError(SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING, {
117+
addresses: missingSigs,
118+
});
119+
}
120+
}

packages/transactions/src/signatures.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,7 @@ export async function partiallySignTransaction<TTransaction extends CompilableTr
4646
const wireMessageBytes = getCompiledMessageEncoder().encode(compiledMessage);
4747
const publicKeySignaturePairs = await Promise.all(
4848
keyPairs.map(keyPair =>
49-
Promise.all([
50-
getAddressFromPublicKey(keyPair.publicKey),
51-
signBytes(keyPair.privateKey, wireMessageBytes as Uint8Array),
52-
]),
49+
Promise.all([getAddressFromPublicKey(keyPair.publicKey), signBytes(keyPair.privateKey, wireMessageBytes)]),
5350
),
5451
);
5552
for (const [signerPublicKey, signature] of publicKeySignaturePairs) {

0 commit comments

Comments
 (0)