Skip to content

Commit

Permalink
refactor(experimental): rename Ed25519Signature and `TransactionSig…
Browse files Browse the repository at this point in the history
…nature` to `SignatureBytes` and `Signature` (#1815)
  • Loading branch information
steveluscher authored Nov 8, 2023
1 parent 8245fa6 commit 205c092
Show file tree
Hide file tree
Showing 48 changed files with 439 additions and 406 deletions.
26 changes: 13 additions & 13 deletions packages/compat/src/__tests__/transaction-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Address } from '@solana/addresses';
import { AccountRole, IInstruction } from '@solana/instructions';
import { Ed25519Signature } from '@solana/keys';
import { SignatureBytes } from '@solana/keys';
import { ITransactionWithSignatures, Nonce } from '@solana/transactions';
import { PublicKey, TransactionInstruction, TransactionMessage, VersionedTransaction } from '@solana/web3.js';

Expand Down Expand Up @@ -186,7 +186,7 @@ describe('fromVersionedTransactionWithBlockhash', () => {
) as unknown as ITransactionWithSignatures;

expect(transaction.signatures).toStrictEqual({
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as Ed25519Signature,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as SignatureBytes,
});
});

Expand Down Expand Up @@ -234,8 +234,8 @@ describe('fromVersionedTransactionWithBlockhash', () => {

expect(transaction.signatures).toStrictEqual({
'3LeBzRE9Yna5zi9R8vdT3MiNQYuEp4gJgVyhhwmqfCtd': new Uint8Array(Array(64).fill(3)),
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': new Uint8Array(Array(64).fill(1)) as Ed25519Signature,
'8kud9bpNvfemXYdTFjs5cZ8fZinBkx8JAnhVmRwJZk5e': new Uint8Array(Array(64).fill(2)) as Ed25519Signature,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': new Uint8Array(Array(64).fill(1)) as SignatureBytes,
'8kud9bpNvfemXYdTFjs5cZ8fZinBkx8JAnhVmRwJZk5e': new Uint8Array(Array(64).fill(2)) as SignatureBytes,
});
});

Expand Down Expand Up @@ -476,7 +476,7 @@ describe('fromVersionedTransactionWithBlockhash', () => {
) as unknown as ITransactionWithSignatures;

expect(transaction.signatures).toStrictEqual({
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as Ed25519Signature,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as SignatureBytes,
});
});

Expand Down Expand Up @@ -524,8 +524,8 @@ describe('fromVersionedTransactionWithBlockhash', () => {

expect(transaction.signatures).toStrictEqual({
'3LeBzRE9Yna5zi9R8vdT3MiNQYuEp4gJgVyhhwmqfCtd': new Uint8Array(Array(64).fill(3)),
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': new Uint8Array(Array(64).fill(1)) as Ed25519Signature,
'8kud9bpNvfemXYdTFjs5cZ8fZinBkx8JAnhVmRwJZk5e': new Uint8Array(Array(64).fill(2)) as Ed25519Signature,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': new Uint8Array(Array(64).fill(1)) as SignatureBytes,
'8kud9bpNvfemXYdTFjs5cZ8fZinBkx8JAnhVmRwJZk5e': new Uint8Array(Array(64).fill(2)) as SignatureBytes,
});
});

Expand Down Expand Up @@ -781,7 +781,7 @@ describe('fromVersionedTransactionWithDurableNonce', () => {
) as unknown as ITransactionWithSignatures;

expect(transaction.signatures).toStrictEqual({
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': signature as Ed25519Signature,
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': signature as SignatureBytes,
});
});

Expand All @@ -807,8 +807,8 @@ describe('fromVersionedTransactionWithDurableNonce', () => {
) as unknown as ITransactionWithSignatures;

expect(transaction.signatures).toStrictEqual({
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': nonceAuthoritySignature as Ed25519Signature,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as Ed25519Signature,
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': nonceAuthoritySignature as SignatureBytes,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as SignatureBytes,
});
});

Expand Down Expand Up @@ -1013,7 +1013,7 @@ describe('fromVersionedTransactionWithDurableNonce', () => {
) as unknown as ITransactionWithSignatures;

expect(transaction.signatures).toStrictEqual({
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': signature as Ed25519Signature,
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': signature as SignatureBytes,
});
});

Expand All @@ -1039,8 +1039,8 @@ describe('fromVersionedTransactionWithDurableNonce', () => {
) as unknown as ITransactionWithSignatures;

expect(transaction.signatures).toStrictEqual({
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': nonceAuthoritySignature as Ed25519Signature,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as Ed25519Signature,
'2KntmCrnaf63tpNb8UMFFjFGGnYYAKQdmW9SbuCiRvhM': nonceAuthoritySignature as SignatureBytes,
'7EqQdEULxWcraVx3mXKFjc84LhCkMGZCkRuDpvcMwJeK': feePayerSignature as SignatureBytes,
});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/compat/src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type Address, assertIsAddress } from '@solana/addresses';
import { pipe } from '@solana/functional';
import type { IAccountMeta, IInstruction } from '@solana/instructions';
import { AccountRole } from '@solana/instructions';
import type { Ed25519Signature } from '@solana/keys';
import type { SignatureBytes } from '@solana/keys';
import {
appendTransactionInstruction,
type Blockhash,
Expand Down Expand Up @@ -86,7 +86,7 @@ function convertSignatures(
if (allZeros) return acc;

const address = staticAccountKeys[index].toBase58() as Address;
return { ...acc, [address]: sig as Ed25519Signature };
return { ...acc, [address]: sig as SignatureBytes };
}, {});
}

Expand Down
65 changes: 63 additions & 2 deletions packages/keys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,43 @@ This package contains utilities for validating, generating, and manipulating add

## Types

### `Ed25519Signature`
### `Signature`

This type represents a 64-byte Ed25519 signature of some data with a private key, as a base58-encoded string.

### `SignatureBytes`

This type represents a 64-byte Ed25519 signature of some data with a private key.

Whenever you need to verify that a particular signature is, in fact, the one that would have been produced by signing some known bytes using the private key associated with some known public key, use the `verifySignature()` function in this package.

## Functions

### `assertIsSignature()`

From time to time you might acquire a string that you expect to be a base58-encoded signature (eg. of a transaction) from an untrusted network API or user input. To assert that such an arbitrary string is in fact an Ed25519 signature, use the `assertIsSignature` function.

```ts
import { assertIsSignature } from '@solana/keys';

// Imagine a function that asserts whether a user-supplied signature is valid or not.
function handleSubmit() {
// We know only that what the user typed conforms to the `string` type.
const signature: string = signatureInput.value;
try {
// If this type assertion function doesn't throw, then
// Typescript will upcast `signature` to `Signature`.
assertIsSignature(signature);
// At this point, `signature` is a `Signature` that can be used with the RPC.
const {
value: [status],
} = await rpc.getSignatureStatuses([signature]).send();
} catch (e) {
// `signature` turned out not to be a base58-encoded signature
}
}
```

### `generateKeyPair()`

Generates an Ed25519 public/private key pair for use with other methods in this package that accept `CryptoKey` objects.
Expand All @@ -36,6 +65,25 @@ import { generateKeyPair } from '@solana/keys';
const { privateKey, publicKey } = await generateKeyPair();
```

### `isSignature()`

This is a type guard that accepts a string as input. It will both return `true` if the string conforms to the `Signature` type and will refine the type for use in your program.

```ts
import { isSignature } from '@solana/keys';

if (isSignature(signature)) {
// At this point, `signature` has been refined to a
// `Signature` that can be used with the RPC.
const {
value: [status],
} = await rpc.getSignatureStatuses([signature]).send();
setSignatureStatus(status);
} else {
setError(`${signature} is not a transaction signature`);
}
```

### `signBytes()`

Given a private `CryptoKey` and a `Uint8Array` of bytes, this method will return the 64-byte Ed25519 signature of that data as a `Uint8Array`.
Expand All @@ -47,9 +95,22 @@ const data = new Uint8Array([1, 2, 3]);
const signature = await signBytes(privateKey, data);
```

### `signature()`

This helper combines _asserting_ that a string is an Ed25519 signature with _coercing_ it to the `Signature` type. It's best used with untrusted input.

```ts
import { signature } from '@solana/keys';

const signature = signature(userSuppliedSignature);
const {
value: [status],
} = await rpc.getSignatureStatuses([signature]).send();
```

### `verifySignature()`

Given a public `CryptoKey`, an `Ed25519Signature`, and a `Uint8Array` of bytes, this method will return `true` if the signature was produced by signing the bytes using the private key associated with the public key, and `false` otherwise.
Given a public `CryptoKey`, some `SignatureBytes`, and a `Uint8Array` of data, this method will return `true` if the signature was produced by signing the data using the private key associated with the public key, and `false` otherwise.

```ts
import { verifySignature } from '@solana/keys';
Expand Down
4 changes: 3 additions & 1 deletion packages/keys/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@
"node": ">=17.4"
},
"dependencies": {
"@solana/assertions": "workspace:*"
"@solana/assertions": "workspace:*",
"@solana/codecs-core": "workspace:*",
"@solana/codecs-strings": "workspace:*"
},
"devDependencies": {
"@solana/eslint-config-solana": "^1.0.2",
Expand Down
17 changes: 17 additions & 0 deletions packages/keys/src/__tests__/coercions-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Signature, signature } from '../signatures';

describe('signature', () => {
it('can coerce to `Signature`', () => {
// Randomly generated
const raw =
'3bwsNoq6EP89sShUAKBeB26aCC3KLGNajRm5wqwr6zRPP3gErZH7erSg3332SVY7Ru6cME43qT35Z7JKpZqCoPaL' as Signature;
const coerced = signature(
'3bwsNoq6EP89sShUAKBeB26aCC3KLGNajRm5wqwr6zRPP3gErZH7erSg3332SVY7Ru6cME43qT35Z7JKpZqCoPaL'
);
expect(coerced).toBe(raw);
});
it('throws on invalid `Signature`', () => {
const thisThrows = () => signature('test');
expect(thisThrows).toThrow('`test` is not a signature');
});
});
132 changes: 127 additions & 5 deletions packages/keys/src/__tests__/signatures-test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { Ed25519Signature, signBytes, verifySignature } from '../signatures';
import { Encoder } from '@solana/codecs-core';
import { getBase58Encoder } from '@solana/codecs-strings';

import { SignatureBytes, signBytes, verifySignature } from '../signatures';

jest.mock('@solana/codecs-strings', () => ({
...jest.requireActual('@solana/codecs-strings'),
getBase58Encoder: jest.fn(),
}));

const MOCK_DATA = new Uint8Array([1, 2, 3]);
const MOCK_DATA_SIGNATURE = new Uint8Array([
66, 111, 184, 228, 239, 189, 127, 46, 23, 168, 117, 69, 58, 143, 132, 164, 112, 189, 203, 228, 183, 151, 0, 23, 179,
181, 52, 75, 112, 225, 150, 128, 184, 164, 36, 21, 101, 205, 115, 28, 127, 221, 24, 135, 229, 8, 69, 232, 16, 225,
44, 229, 17, 236, 206, 174, 102, 207, 79, 253, 96, 7, 174, 10,
]) as Ed25519Signature;
]) as SignatureBytes;
const MOCK_PKCS8_PRIVATE_KEY =
// prettier-ignore
new Uint8Array([
Expand Down Expand Up @@ -48,6 +56,120 @@ const MOCK_PUBLIC_KEY_BYTES = new Uint8Array([
0xde, 0x31, 0xa1, 0xc9, 0x42, 0x87, 0xcb, 0x43, 0xf0, 0x5f, 0xc9, 0xf2, 0xb5,
]);

// real implementations
const originalBase58Module = jest.requireActual('@solana/codecs-strings');
const originalGetBase58Encoder = originalBase58Module.getBase58Encoder();

describe('assertIsSignature()', () => {
let assertIsSignature: typeof import('../signatures').assertIsSignature;
// Reload `assertIsSignature` before each test to reset memoized state
beforeEach(async () => {
await jest.isolateModulesAsync(async () => {
const base58ModulePromise =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import('../signatures');
assertIsSignature = (await base58ModulePromise).assertIsSignature;
});
});

describe('using the real base58 implementation', () => {
beforeEach(() => {
// use real implementation
jest.mocked(getBase58Encoder).mockReturnValue(originalGetBase58Encoder);
});

it('throws when supplied a non-base58 string', () => {
expect(() => {
assertIsSignature('not-a-base-58-encoded-string');
}).toThrow();
});
it('throws when the decoded byte array has a length other than 32 bytes', () => {
expect(() => {
assertIsSignature(
// 63 bytes [128, ..., 128]
'1'.repeat(63)
);
}).toThrow();
});
it('does not throw when supplied a base-58 encoded signature', () => {
expect(() => {
// 64 bytes [0, ..., 0]
assertIsSignature('1'.repeat(64));

// example signatures
assertIsSignature(
'5HkW5GttYoahVHaujuxEyfyq7RwvoKpc94ko5Fq9GuYdyhejg9cHcqm1MjEvHsjaADRe6hVBqB2E4RQgGgxeA2su'
);
assertIsSignature(
'2VZm7DkqSKaHxsGiAuVuSkvEbGWf7JrfRdPTw42WKuJC8qw7yQbGL5AE7UxHH3tprgmT9EVbambnK9h3PLpvMvES'
);
assertIsSignature(
'5sXRtm61WrRGRTjJ6f2anKUWt86Y4V9gWU4WUpue4T4Zh6zuvFoSyaX5LkEtChfqVC8oHdqLo2eUXbhVduThBdfG'
);
assertIsSignature(
'2Dy6Qai5JyChoP4BKoh9KAYhpD96CUhmEce1GJ8HpV5h8Q4CgUt8KZQzhVNDEQYcjARxYyBNhNjhKUGC2XLZtCCm'
);
}).not.toThrow();
});
it('returns undefined when supplied a base-58 encoded signature', () => {
// 64 bytes [0, ..., 0]
expect(assertIsSignature('1'.repeat(64))).toBeUndefined();
});
});

describe('using a mock base58 implementation', () => {
const mockEncode = jest.fn();
beforeEach(() => {
// use mock implementation
mockEncode.mockClear();
jest.mocked(getBase58Encoder).mockReturnValue({ encode: mockEncode } as unknown as Encoder<string>);
});
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88].forEach(
len => {
it(`attempts to decode input strings of exactly ${len} characters`, () => {
try {
assertIsSignature('1'.repeat(len));
// eslint-disable-next-line no-empty
} catch {}
expect(mockEncode).toHaveBeenCalledTimes(1);
});
}
);
it('does not attempt to decode too-short input strings', () => {
try {
assertIsSignature(
// 63 bytes [0, ..., 0]
'1'.repeat(63)
);
// eslint-disable-next-line no-empty
} catch {}
expect(mockEncode).not.toHaveBeenCalled();
});
it('does not attempt to decode too-long input strings', () => {
try {
assertIsSignature(
// 65 bytes [0, 255, ..., 255]
'167rpwLCuS5DGA8KGZXKsVQ7dnPb9goRLoKfgGbLfQg9WoLUgNY77E2jT11fem3coV9nAkguBACzrU1iyZM4B8roQ'
);
// eslint-disable-next-line no-empty
} catch {}
expect(mockEncode).not.toHaveBeenCalled();
});
it('memoizes getBase58Encoder when called multiple times', () => {
try {
assertIsSignature('1'.repeat(64));
// eslint-disable-next-line no-empty
} catch {}
try {
assertIsSignature('1'.repeat(64));
// eslint-disable-next-line no-empty
} catch {}
expect(jest.mocked(getBase58Encoder)).toHaveBeenCalledTimes(1);
});
});
});

describe('sign', () => {
it('produces the expected signature given a private key', async () => {
expect.assertions(1);
Expand Down Expand Up @@ -89,19 +211,19 @@ describe('verify', () => {
});
it('returns `false` when a bad signature is supplied for a given payload', async () => {
expect.assertions(1);
const badSignature = new Uint8Array(Array(64).fill(1)) as Ed25519Signature;
const badSignature = new Uint8Array(Array(64).fill(1)) as SignatureBytes;
const result = await verifySignature(mockPublicKey, badSignature, MOCK_DATA);
expect(result).toBe(false);
});
it('returns `false` when the signature 65 bytes long', async () => {
expect.assertions(1);
const badSignature = new Uint8Array([...MOCK_DATA_SIGNATURE, 1]) as Ed25519Signature;
const badSignature = new Uint8Array([...MOCK_DATA_SIGNATURE, 1]) as SignatureBytes;
const result = await verifySignature(mockPublicKey, badSignature, MOCK_DATA);
expect(result).toBe(false);
});
it('returns `false` when the signature 63 bytes long', async () => {
expect.assertions(1);
const badSignature = MOCK_DATA_SIGNATURE.slice(0, 63) as Ed25519Signature;
const badSignature = MOCK_DATA_SIGNATURE.slice(0, 63) as SignatureBytes;
const result = await verifySignature(mockPublicKey, badSignature, MOCK_DATA);
expect(result).toBe(false);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/keys/src/__typetests__/coercions-typetests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Signature, signature } from '../signatures';

signature('x') satisfies Signature;
Loading

0 comments on commit 205c092

Please sign in to comment.