Skip to content

Commit f591dea

Browse files
Add SignatureBytes assertion, guard, and coercion methods (#901)
* do it * do it * add docs * update docs * ship it * ship it * Add changeset * Update packages/keys/src/__tests__/signatures-test.ts Co-authored-by: Steven Luscher <steveluscher@users.noreply.github.com> * Update packages/keys/src/__typetests__/signatures-typetest.ts Co-authored-by: Steven Luscher <steveluscher@users.noreply.github.com> * Update packages/keys/src/__tests__/signatures-test.ts Co-authored-by: Steven Luscher <steveluscher@users.noreply.github.com> * address nits * fix typetest * remove empty line --------- Co-authored-by: Steven Luscher <steveluscher@users.noreply.github.com>
1 parent 1ff2219 commit f591dea

File tree

6 files changed

+152
-12
lines changed

6 files changed

+152
-12
lines changed

.changeset/chilly-shoes-rest.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solana/keys": patch
3+
---
4+
5+
Added assertion (`assertIsSignatureBytes`), guard (`isSignatureBytes`), and coercion (`signatureBytes`) methods to make it easier to work with callsites that demand a `SignatureBytes` type

docs/content/api/index.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,18 +831,21 @@ Welcome to the Solana Kit API Reference! It covers a total of **39 packages** mo
831831

832832
</div>
833833

834-
<p className="text-xs tracking-wider uppercase mt-8 -mb-2 text-linen-500 dark:text-linen-400">Functions (10)</p>
834+
<p className="text-xs tracking-wider uppercase mt-8 -mb-2 text-linen-500 dark:text-linen-400">Functions (13)</p>
835835

836836
<div className="*:columns-[20rem] *:gap-8">
837837

838838
[assertIsSignature](/api/functions/assertIsSignature) \
839+
[assertIsSignatureBytes](/api/functions/assertIsSignatureBytes) \
839840
[createKeyPairFromBytes](/api/functions/createKeyPairFromBytes) \
840841
[createKeyPairFromPrivateKeyBytes](/api/functions/createKeyPairFromPrivateKeyBytes) \
841842
[createPrivateKeyFromBytes](/api/functions/createPrivateKeyFromBytes) \
842843
[generateKeyPair](/api/functions/generateKeyPair) \
843844
[getPublicKeyFromPrivateKey](/api/functions/getPublicKeyFromPrivateKey) \
844845
[isSignature](/api/functions/isSignature) \
846+
[isSignatureBytes](/api/functions/isSignatureBytes) \
845847
[signature](/api/functions/signature) \
848+
[signatureBytes](/api/functions/signatureBytes) \
846849
[signBytes](/api/functions/signBytes) \
847850
[verifySignature](/api/functions/verifySignature)
848851

packages/keys/src/__tests__/coercions-test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
SolanaError,
55
} from '@solana/errors';
66

7-
import { Signature, signature } from '../signatures';
7+
import { Signature, signature, signatureBytes } from '../signatures';
88

99
describe('signature', () => {
1010
it('can coerce to `Signature`', () => {
@@ -36,3 +36,19 @@ describe('signature', () => {
3636
);
3737
});
3838
});
39+
40+
describe('signatureBytes', () => {
41+
it('can coerce to `SignatureBytes`', () => {
42+
const raw = new Uint8Array(64);
43+
const coerced = signatureBytes(raw);
44+
expect(coerced).toBe(raw);
45+
});
46+
it.each([63, 65])('throws on a `SignatureBytes` whose byte length is %s', actualLength => {
47+
const thisThrows = () => signatureBytes(new Uint8Array(actualLength));
48+
expect(thisThrows).toThrow(
49+
new SolanaError(SOLANA_ERROR__KEYS__INVALID_SIGNATURE_BYTE_LENGTH, {
50+
actualLength,
51+
}),
52+
);
53+
});
54+
});

packages/keys/src/__tests__/signatures-test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createEncoder, VariableSizeEncoder } from '@solana/codecs-core';
22
import { getBase58Encoder } from '@solana/codecs-strings';
33

44
import { createPrivateKeyFromBytes } from '../private-key';
5-
import { SignatureBytes, signBytes, verifySignature } from '../signatures';
5+
import { assertIsSignatureBytes, SignatureBytes, signBytes, verifySignature } from '../signatures';
66

77
jest.mock('@solana/codecs-strings', () => ({
88
...jest.requireActual('@solana/codecs-strings'),
@@ -14,7 +14,7 @@ const MOCK_DATA_SIGNATURE = new Uint8Array([
1414
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,
1515
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,
1616
44, 229, 17, 236, 206, 174, 102, 207, 79, 253, 96, 7, 174, 10,
17-
]) as SignatureBytes;
17+
]);
1818
const MOCK_PRIVATE_KEY_BYTES = new Uint8Array([
1919
0xeb, 0xfa, 0x65, 0xeb, 0x93, 0xdc, 0x79, 0x15, 0x7a, 0xba, 0xde, 0xa2, 0xf7, 0x94, 0x37, 0x9d, 0xfc, 0x07, 0x1d,
2020
0x68, 0x86, 0x87, 0x37, 0x6d, 0xc5, 0xd5, 0xa0, 0x54, 0x12, 0x1d, 0x34, 0x4a,
@@ -143,6 +143,33 @@ describe('assertIsSignature()', () => {
143143
});
144144
});
145145

146+
describe('assertIsSignatureBytes()', () => {
147+
it('throws when supplied an empty byte array', () => {
148+
expect(() => {
149+
assertIsSignatureBytes(new Uint8Array(0));
150+
}).toThrow();
151+
});
152+
it.each([63, 65])('throws when the byte array has a length of %s', length => {
153+
expect(() => {
154+
assertIsSignatureBytes(new Uint8Array(length));
155+
}).toThrow();
156+
});
157+
it('does not throw when supplied a zeroed 64-byte bytearray', () => {
158+
expect(() => {
159+
assertIsSignatureBytes(new Uint8Array(64));
160+
}).not.toThrow();
161+
});
162+
it('does not throw when supplied a 64-byte signature as a bytearray', () => {
163+
expect(() => {
164+
assertIsSignatureBytes(MOCK_DATA_SIGNATURE);
165+
}).not.toThrow();
166+
});
167+
it('returns undefined when supplied a 64-byte byte array', () => {
168+
// 64 bytes [0, ..., 0]
169+
expect(assertIsSignatureBytes(new Uint8Array(64))).toBeUndefined();
170+
});
171+
});
172+
146173
describe('sign', () => {
147174
it('produces the expected signature given a private key', async () => {
148175
expect.assertions(1);
@@ -171,7 +198,7 @@ describe('verify', () => {
171198
});
172199
it('returns `true` when the correct signature is supplied for a given payload', async () => {
173200
expect.assertions(1);
174-
const result = await verifySignature(mockPublicKey, MOCK_DATA_SIGNATURE, MOCK_DATA);
201+
const result = await verifySignature(mockPublicKey, MOCK_DATA_SIGNATURE as SignatureBytes, MOCK_DATA);
175202
expect(result).toBe(true);
176203
});
177204
it('returns `false` when a bad signature is supplied for a given payload', async () => {

packages/keys/src/__typetests__/signatures-typetest.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EncodedString } from '@solana/nominal-types';
22

3-
import { Signature, signature } from '../signatures';
3+
import { Signature, signature, SignatureBytes, signatureBytes } from '../signatures';
44

55
// [DESCRIBE] signature()
66
{
@@ -18,3 +18,20 @@ import { Signature, signature } from '../signatures';
1818
signature satisfies EncodedString<string, 'base58'>;
1919
}
2020
}
21+
22+
// [DESCRIBE] signatureBytes()
23+
{
24+
// It returns a `SignatureBytes`
25+
{
26+
signatureBytes(new Uint8Array(0)) satisfies SignatureBytes;
27+
}
28+
}
29+
30+
// [DESCRIBE] SignatureBytes
31+
{
32+
// It satisfies the `Uint8Array` type
33+
{
34+
const signatureBytes = null as unknown as SignatureBytes;
35+
signatureBytes satisfies Uint8Array;
36+
}
37+
}

packages/keys/src/signatures.ts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,41 @@ export function assertIsSignature(putativeSignature: string): asserts putativeSi
6868
}
6969
// Slow-path; actually attempt to decode the input string.
7070
const bytes = base58Encoder.encode(putativeSignature);
71-
const numBytes = bytes.byteLength;
71+
assertIsSignatureBytes(bytes);
72+
}
73+
74+
/**
75+
* Asserts that an arbitrary `ReadonlyUint8Array` is an Ed25519 signature.
76+
*
77+
* Useful when you receive a `ReadonlyUint8Array` from an external interface (like the browser wallets' `signMessage` API) that you expect to
78+
* represent an Ed25519 signature.
79+
*
80+
* @example
81+
* ```ts
82+
* import { assertIsSignatureBytes } from '@solana/keys';
83+
*
84+
* // Imagine a function that verifies a signature.
85+
* function verifySignature() {
86+
* // We know only that the input conforms to the `ReadonlyUint8Array` type.
87+
* const signatureBytes: ReadonlyUint8Array = signatureBytesInput;
88+
* try {
89+
* // If this type assertion function doesn't throw, then
90+
* // Typescript will upcast `signatureBytes` to `SignatureBytes`.
91+
* assertIsSignatureBytes(signatureBytes);
92+
* // At this point, `signatureBytes` is a `SignatureBytes` that can be used with `verifySignature`.
93+
* if (!(await verifySignature(publicKey, signatureBytes, data))) {
94+
* throw new Error('The data were *not* signed by the private key associated with `publicKey`');
95+
* }
96+
* } catch (e) {
97+
* // `signatureBytes` turned out not to be a 64-byte Ed25519 signature
98+
* }
99+
* }
100+
* ```
101+
*/
102+
export function assertIsSignatureBytes(
103+
putativeSignatureBytes: ReadonlyUint8Array,
104+
): asserts putativeSignatureBytes is SignatureBytes {
105+
const numBytes = putativeSignatureBytes.byteLength;
72106
if (numBytes !== 64) {
73107
throw new SolanaError(SOLANA_ERROR__KEYS__INVALID_SIGNATURE_BYTE_LENGTH, {
74108
actualLength: numBytes,
@@ -110,11 +144,30 @@ export function isSignature(putativeSignature: string): putativeSignature is Sig
110144
}
111145
// Slow-path; actually attempt to decode the input string.
112146
const bytes = base58Encoder.encode(putativeSignature);
113-
const numBytes = bytes.byteLength;
114-
if (numBytes !== 64) {
115-
return false;
116-
}
117-
return true;
147+
return isSignatureBytes(bytes);
148+
}
149+
150+
/**
151+
* A type guard that accepts a `ReadonlyUint8Array` as input. It will both return `true` if the `ReadonlyUint8Array` conforms to
152+
* the {@link SignatureBytes} type and will refine the type for use in your program.
153+
*
154+
* @example
155+
* ```ts
156+
* import { isSignatureBytes } from '@solana/keys';
157+
*
158+
* if (isSignatureBytes(signatureBytes)) {
159+
* // At this point, `signatureBytes` has been refined to a
160+
* // `SignatureBytes` that can be used with `verifySignature`.
161+
* if (!(await verifySignature(publicKey, signatureBytes, data))) {
162+
* throw new Error('The data were *not* signed by the private key associated with `publicKey`');
163+
* }
164+
* } else {
165+
* setError(`${signatureBytes} is not a 64-byte Ed25519 signature`);
166+
* }
167+
* ```
168+
*/
169+
export function isSignatureBytes(putativeSignatureBytes: ReadonlyUint8Array): putativeSignatureBytes is SignatureBytes {
170+
return putativeSignatureBytes.byteLength === 64;
118171
}
119172

120173
/**
@@ -155,6 +208,25 @@ export function signature(putativeSignature: string): Signature {
155208
return putativeSignature;
156209
}
157210

211+
/**
212+
* This helper combines _asserting_ that a `ReadonlyUint8Array` is an Ed25519 signature with _coercing_ it to the
213+
* {@link SignatureBytes} type. It's best used with untrusted input.
214+
*
215+
* @example
216+
* ```ts
217+
* import { signatureBytes } from '@solana/keys';
218+
*
219+
* const signature = signatureBytes(userSuppliedSignatureBytes);
220+
* if (!(await verifySignature(publicKey, signature, data))) {
221+
* throw new Error('The data were *not* signed by the private key associated with `publicKey`');
222+
* }
223+
* ```
224+
*/
225+
export function signatureBytes(putativeSignatureBytes: ReadonlyUint8Array): SignatureBytes {
226+
assertIsSignatureBytes(putativeSignatureBytes);
227+
return putativeSignatureBytes;
228+
}
229+
158230
/**
159231
* Given a public [`CryptoKey`](https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey), some
160232
* {@link SignatureBytes}, and a `Uint8Array` of data, this method will return `true` if the

0 commit comments

Comments
 (0)