Skip to content

Commit

Permalink
refactor(experimental): make signTransaction assert the transaction…
Browse files Browse the repository at this point in the history
… is fully signed; add `partiallySignTransaction`
  • Loading branch information
steveluscher committed Nov 10, 2023
1 parent 205c092 commit eaf1e49
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 16 deletions.
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: 'abc' as Address<'abc'>,
},
],
} 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: 2,
numReadonlySignerAccounts: 1,
numSignerAccounts: 2,
},
instructions: [
{
accountIndices: [/* mockPublicKeyAddressB */ 1, /* mockPublicKeyAddressC */ 2],
programAddressIndex: 3 /* 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

0 comments on commit eaf1e49

Please sign in to comment.