Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(experimental): make signTransaction assert the transaction is fully signed; add partiallySignTransaction #1820

Merged
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
151 changes: 142 additions & 9 deletions packages/transactions/src/__tests__/signatures-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
assertTransactionIsFullySigned,
getSignatureFromTransaction,
ITransactionWithSignatures,
partiallySignTransaction,
signTransaction,
} from '../signatures';

Expand Down Expand Up @@ -53,8 +54,8 @@ describe('getSignatureFromTransaction', () => {
});
});

describe('signTransaction', () => {
const MOCK_TRANSACTION = {} as unknown as Parameters<typeof signTransaction>[1];
describe('partiallySignTransaction', () => {
const MOCK_TRANSACTION = {} as unknown as Parameters<typeof partiallySignTransaction>[1];
const MOCK_SIGNATURE_A = new Uint8Array(Array(64).fill(1));
const MOCK_SIGNATURE_B = new Uint8Array(Array(64).fill(2));
const MOCK_SIGNATURE_C = new Uint8Array(Array(64).fill(3));
Expand Down Expand Up @@ -120,23 +121,23 @@ describe('signTransaction', () => {
});
it("returns a transaction object having the first signer's signature", async () => {
expect.assertions(1);
const partiallySignedTransactionPromise = signTransaction([mockKeyPairA], MOCK_TRANSACTION);
const partiallySignedTransactionPromise = partiallySignTransaction([mockKeyPairA], MOCK_TRANSACTION);
await expect(partiallySignedTransactionPromise).resolves.toHaveProperty(
'signatures',
expect.objectContaining({ [mockPublicKeyAddressA]: MOCK_SIGNATURE_A })
);
});
it("returns a transaction object having the second signer's signature", async () => {
expect.assertions(1);
const partiallySignedTransactionPromise = signTransaction([mockKeyPairB], MOCK_TRANSACTION);
const partiallySignedTransactionPromise = partiallySignTransaction([mockKeyPairB], MOCK_TRANSACTION);
await expect(partiallySignedTransactionPromise).resolves.toHaveProperty(
'signatures',
expect.objectContaining({ [mockPublicKeyAddressB]: MOCK_SIGNATURE_B })
);
});
it('returns a transaction object having multiple signatures', async () => {
expect.assertions(1);
const partiallySignedTransactionPromise = signTransaction(
const partiallySignedTransactionPromise = partiallySignTransaction(
[mockKeyPairA, mockKeyPairB, mockKeyPairC],
MOCK_TRANSACTION
);
Expand All @@ -155,7 +156,7 @@ describe('signTransaction', () => {
...MOCK_TRANSACTION,
signatures: { [mockPublicKeyAddressB]: MOCK_SIGNATURE_B },
};
const partiallySignedTransactionPromise = signTransaction(
const partiallySignedTransactionPromise = partiallySignTransaction(
[mockKeyPairA],
mockTransactionWithSignatureForSignerA
);
Expand All @@ -174,7 +175,7 @@ describe('signTransaction', () => {
...MOCK_TRANSACTION,
signatures: startingSignatures,
};
const { signatures } = await signTransaction([mockKeyPairA], mockTransactionWithSignatureForSignerA);
const { signatures } = await partiallySignTransaction([mockKeyPairA], mockTransactionWithSignatureForSignerA);
expect(signatures).not.toBe(startingSignatures);
expect(signatures).toMatchObject({
[mockPublicKeyAddressA]: MOCK_SIGNATURE_A,
Expand All @@ -188,7 +189,7 @@ describe('signTransaction', () => {
...MOCK_TRANSACTION,
signatures: startingSignatures,
};
const { signatures } = await signTransaction(
const { signatures } = await partiallySignTransaction(
[mockKeyPairA, mockKeyPairC],
mockTransactionWithSignatureForSignerA
);
Expand All @@ -201,7 +202,139 @@ describe('signTransaction', () => {
});
it('freezes the object', async () => {
expect.assertions(1);
await expect(signTransaction([mockKeyPairA], MOCK_TRANSACTION)).resolves.toBeFrozenObject();
await expect(partiallySignTransaction([mockKeyPairA], MOCK_TRANSACTION)).resolves.toBeFrozenObject();
});
});

describe('signTransaction', () => {
const mockPublicKeyAddressA = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' as Address<'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'>;
const mockPublicKeyAddressB = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' as Address<'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'>;
const MOCK_TRANSACTION = {
feePayer: mockPublicKeyAddressA,
instructions: [
{
accounts: [{ address: mockPublicKeyAddressB, role: AccountRole.READONLY_SIGNER }],
programAddress: '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>,
},
],
} as unknown as Parameters<typeof signTransaction>[1];
const MOCK_SIGNATURE_A = new Uint8Array(Array(64).fill(1));
const MOCK_SIGNATURE_B = new Uint8Array(Array(64).fill(2));
const mockKeyPairA = { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey } as CryptoKeyPair;
const mockKeyPairB = { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey } as CryptoKeyPair;
beforeEach(async () => {
(compileMessage as jest.Mock).mockReturnValue({
header: {
numReadonlyNonSignerAccounts: 1,
numReadonlySignerAccounts: 1,
numSignerAccounts: 2,
},
instructions: [
{
accountIndices: [/* mockPublicKeyAddressB */ 1],
programAddressIndex: 2 /* system program */,
},
],
lifetimeToken: 'fBrpLg4qfyVH8e3z4zbjAXy4kCZP2jCFdqy113vndcj' as Blockhash,
staticAccounts: [
/* 0: fee payer */ mockPublicKeyAddressA,
/* 1: read-only instruction signer address */ mockPublicKeyAddressB,
/* 2: system program */ '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>,
],
version: 0,
} as CompiledMessage);
(getAddressFromPublicKey as jest.Mock).mockImplementation(async publicKey => {
switch (publicKey) {
case mockKeyPairA.publicKey:
return mockPublicKeyAddressA;
case mockKeyPairB.publicKey:
return mockPublicKeyAddressB;
default:
return '99999999999999999999999999999999' as Address<'99999999999999999999999999999999'>;
}
});
(signBytes as jest.Mock).mockImplementation(async secretKey => {
switch (secretKey) {
case mockKeyPairA.privateKey:
return MOCK_SIGNATURE_A;
case mockKeyPairB.privateKey:
return MOCK_SIGNATURE_B;
default:
return new Uint8Array(Array(64).fill(0xff));
}
});
(getAddressEncoder as jest.Mock).mockReturnValue({
encode: jest.fn().mockReturnValue('fAkEbAsE58AdDrEsS'),
});
(getAddressDecoder as jest.Mock).mockReturnValue({});
(getAddressCodec as jest.Mock).mockReturnValue({
encode: jest.fn().mockReturnValue('fAkEbAsE58AdDrEsS'),
});
});
it('fatals when missing a signer', async () => {
expect.assertions(1);
const signedTransactionPromise = signTransaction([mockKeyPairA], MOCK_TRANSACTION);
await expect(signedTransactionPromise).rejects.toThrow(
`Transaction is missing signature for address \`${mockPublicKeyAddressB}\``
);
});
it('returns a transaction object having multiple signatures', async () => {
expect.assertions(1);
const signedTransactionPromise = signTransaction([mockKeyPairA, mockKeyPairB], MOCK_TRANSACTION);
await expect(signedTransactionPromise).resolves.toHaveProperty(
'signatures',
expect.objectContaining({
[mockPublicKeyAddressA]: MOCK_SIGNATURE_A,
[mockPublicKeyAddressB]: MOCK_SIGNATURE_B,
})
);
});
it('returns a transaction object without overwriting the existing signatures', async () => {
expect.assertions(1);
const mockTransactionWithSignatureForSignerA = {
...MOCK_TRANSACTION,
signatures: { [mockPublicKeyAddressB]: MOCK_SIGNATURE_B },
};
const signedTransactionPromise = signTransaction([mockKeyPairA], mockTransactionWithSignatureForSignerA);
await expect(signedTransactionPromise).resolves.toHaveProperty(
'signatures',
expect.objectContaining({
[mockPublicKeyAddressA]: MOCK_SIGNATURE_A,
[mockPublicKeyAddressB]: MOCK_SIGNATURE_B,
})
);
});
it("does not mutate the original signatures when updating a transaction's signatures", async () => {
expect.assertions(2);
const startingSignatures = { [mockPublicKeyAddressB]: MOCK_SIGNATURE_B } as const;
const mockTransactionWithSignatureForSignerA = {
...MOCK_TRANSACTION,
signatures: startingSignatures,
};
const { signatures } = await signTransaction([mockKeyPairA], mockTransactionWithSignatureForSignerA);
expect(signatures).not.toBe(startingSignatures);
expect(signatures).toMatchObject({
[mockPublicKeyAddressA]: MOCK_SIGNATURE_A,
[mockPublicKeyAddressB]: MOCK_SIGNATURE_B,
});
});
it("does not mutate the original signatures when updating a transaction's signatures with multiple signers", async () => {
expect.assertions(2);
const startingSignatures = { [mockPublicKeyAddressB]: MOCK_SIGNATURE_B } as const;
const mockTransactionWithSignatureForSignerA = {
...MOCK_TRANSACTION,
signatures: startingSignatures,
};
const { signatures } = await signTransaction([mockKeyPairA], mockTransactionWithSignatureForSignerA);
expect(signatures).not.toBe(startingSignatures);
expect(signatures).toMatchObject({
[mockPublicKeyAddressA]: MOCK_SIGNATURE_A,
[mockPublicKeyAddressB]: MOCK_SIGNATURE_B,
});
});
it('freezes the object', async () => {
expect.assertions(1);
await expect(signTransaction([mockKeyPairA, mockKeyPairB], MOCK_TRANSACTION)).resolves.toBeFrozenObject();
});
});

Expand Down
87 changes: 81 additions & 6 deletions packages/transactions/src/__typetests__/transaction-typetests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ITransactionWithBlockhashLifetime,
ITransactionWithSignatures,
Nonce,
partiallySignTransaction,
prependTransactionInstruction,
setTransactionLifetimeUsingBlockhash,
setTransactionLifetimeUsingDurableNonce,
Expand Down Expand Up @@ -237,6 +238,80 @@ async () => {
IDurableNonceTransaction &
ITransactionWithSignatures;

// partiallySignTransaction
// (blockhash)
partiallySignTransaction(
[mockSigner],
null as unknown as Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime
) satisfies Promise<
Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
>;
partiallySignTransaction(
[mockSigner],
null as unknown as Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime
// @ts-expect-error Version should match
) satisfies Promise<
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
>;
partiallySignTransaction(
[mockSigner],
null as unknown as Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime
) satisfies Promise<
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
>;
partiallySignTransaction(
[mockSigner],
null as unknown as Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
) satisfies Promise<
Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
>;
partiallySignTransaction(
[mockSigner],
null as unknown as Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
// @ts-expect-error Version should match
) satisfies Promise<
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
>;
partiallySignTransaction(
[mockSigner],
null as unknown as Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
) satisfies Promise<
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
>;

// signTransaction
// (checks)
signTransaction(
Expand Down Expand Up @@ -270,7 +345,7 @@ async () => {
Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
IFullySignedTransaction
>;
signTransaction(
[mockSigner],
Expand All @@ -282,7 +357,7 @@ async () => {
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
IFullySignedTransaction
>;
signTransaction(
[mockSigner],
Expand All @@ -293,7 +368,7 @@ async () => {
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
IFullySignedTransaction
>;
signTransaction(
[mockSigner],
Expand All @@ -305,7 +380,7 @@ async () => {
Extract<Transaction, { version: 'legacy' }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
IFullySignedTransaction
>;
signTransaction(
[mockSigner],
Expand All @@ -318,7 +393,7 @@ async () => {
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
IFullySignedTransaction
>;
signTransaction(
[mockSigner],
Expand All @@ -330,7 +405,7 @@ async () => {
Extract<Transaction, { version: 0 }> &
ITransactionWithFeePayer<'feePayer'> &
ITransactionWithBlockhashLifetime &
ITransactionWithSignatures
IFullySignedTransaction
>;

// compileMessage
Expand Down
12 changes: 11 additions & 1 deletion packages/transactions/src/signatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function getSignatureFromTransaction(
return transactionSignature as Signature;
}

export async function signTransaction<TTransaction extends CompilableTransaction>(
export async function partiallySignTransaction<TTransaction extends CompilableTransaction>(
keyPairs: CryptoKeyPair[],
transaction: TTransaction | (TTransaction & ITransactionWithSignatures)
): Promise<TTransaction & ITransactionWithSignatures> {
Expand All @@ -59,6 +59,16 @@ export async function signTransaction<TTransaction extends CompilableTransaction
return out;
}

export async function signTransaction<TTransaction extends CompilableTransaction>(
keyPairs: CryptoKeyPair[],
transaction: TTransaction | (TTransaction & ITransactionWithSignatures)
): Promise<TTransaction & IFullySignedTransaction> {
const out = await partiallySignTransaction(keyPairs, transaction);
assertTransactionIsFullySigned(out);
Object.freeze(out);
return out;
}

export function assertTransactionIsFullySigned<TTransaction extends CompilableTransaction>(
transaction: TTransaction & ITransactionWithSignatures
): asserts transaction is TTransaction & IFullySignedTransaction {
Expand Down
Loading