Skip to content

Commit

Permalink
refactor(experimental): add blockhash codec and type assertions
Browse files Browse the repository at this point in the history
Similar to the `AddressCodec` in `@solana/addresses`, we could use a typed
32-byte codec that can chain base64 encoding into its serialization, for
`Blockhash`.

Here I've added the same kind of combined codec as `getAddressCodec` to the
`blockhash` type in `@solana/rpc-types`.

Note: The implementation is almost identical, with the exception of the types,
and the tests are exactly the same as `getAddressCodec`.

I've also added some more assertions to `blockhash` to keep the API consistent.
  • Loading branch information
buffalojoec committed Mar 14, 2024
1 parent f9509c7 commit 19f6725
Show file tree
Hide file tree
Showing 2 changed files with 176 additions and 5 deletions.
104 changes: 103 additions & 1 deletion packages/rpc-types/src/__tests__/blockhash-test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import type { VariableSizeEncoder } from '@solana/codecs-core';
import { getBase58Encoder } from '@solana/codecs-strings';
import { getBase58Decoder, getBase58Encoder } from '@solana/codecs-strings';
import {
SOLANA_ERROR__BLOCKHASH_STRING_LENGTH_OUT_OF_RANGE,
SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH,
SOLANA_ERROR__CODECS__INVALID_STRING_FOR_BASE,
SOLANA_ERROR__INVALID_BLOCKHASH_BYTE_LENGTH,
SolanaError,
} from '@solana/errors';

import { Blockhash, getBlockhashCodec, getBlockhashComparator } from '../blockhash';

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

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

describe('assertIsBlockhash()', () => {
let assertIsBlockhash: typeof import('../blockhash').assertIsBlockhash;
Expand Down Expand Up @@ -130,4 +135,101 @@ describe('assertIsBlockhash()', () => {
expect(jest.mocked(getBase58Encoder)).toHaveBeenCalledTimes(1);
});
});

describe('getBlockhashCodec', () => {
let blockhash: ReturnType<typeof getBlockhashCodec>;
beforeEach(() => {
// use real implementations
jest.mocked(getBase58Encoder).mockReturnValue(originalGetBase58Encoder);
jest.mocked(getBase58Decoder).mockReturnValue(originalGetBase58Decoder);

blockhash = getBlockhashCodec();
});
it('serializes a base58 encoded blockhash into a 32-byte buffer', () => {
expect(blockhash.encode('4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsxmfVF5jJpYP' as Blockhash)).toEqual(
new Uint8Array([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0,
]),
);
});
it('deserializes a byte buffer representing an blockhash into a base58 encoded blockhash', () => {
expect(
blockhash.decode(
new Uint8Array([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32,
// Followed by extra bytes not part of the blockhash
33, 34,
]),
),
).toBe('4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw' as Blockhash);
});
it('fatals when trying to deserialize a byte buffer shorter than 32-bytes', () => {
const tooShortBuffer = new Uint8Array(Array(31).fill(0));
expect(() => blockhash.decode(tooShortBuffer)).toThrow(
new SolanaError(SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, {
bytesLength: 31,
codecDescription: 'fixCodec',
expected: 32,
}),
);
});
it('memoizes getBase58Encoder and getBase58Decoder when called multiple times', async () => {
expect.assertions(2);

// reload the module to reset memoized state
let getBlockhashCodec: typeof import('../blockhash').getBlockhashCodec;
await jest.isolateModulesAsync(async () => {
const base58ModulePromise =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import('../blockhash');
getBlockhashCodec = (await base58ModulePromise).getBlockhashCodec;
});

blockhash = getBlockhashCodec!();
blockhash.encode('4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsxmfVF5jJpYP' as Blockhash);

blockhash = getBlockhashCodec!();
blockhash.encode('4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsxmfVF5jJpYP' as Blockhash);

expect(jest.mocked(getBase58Encoder)).toHaveBeenCalledTimes(1);
expect(jest.mocked(getBase58Decoder)).toHaveBeenCalledTimes(1);
});
});

describe('getAddressComparator', () => {
it('sorts base 58 blockhashes', () => {
expect(
// These blockhashes were chosen such that sorting these conventionally (ie. using
// the default `Array.sort`) or numerically (ie. on the basis of the underlying
// numerical value of the blockhash) would fail to produce the expected output. This
// exercises the 'specialness' of the base 58 encoded blockhash comparator.
[
'Ht1VrhoyhwMGMpBBi89BPdJp5R39Mu49suKx3A22W9Qs',
'J9ZSLc9qPg3FR8UqfN6ae1QkVReUmnpLgQqFkGEPqmod',
'6JYSQqSHY1E5JDwEfgWMieozqA1KCwiP2cH69to9eWKH',
'7YR1xA7yzFAT4yQCsS4rpowjU1tsh5YUJd9hWMHRppcX',
'7grJ9YUAEHxckLFqCY7fq8cM1UrragNSuPH1dvwJ8EEK',
'AJBPNWCjVLwxff2eJynW56cMRCGmyU4y3vbuvtVdgVgb',
'B8A2zUEDtJjR7nrokNUJYhgUQiwEBzC88rZc6WUE5ZeF',
'BKggsVVp7yLmXtPuBDtC3FXBzvLyyye3Q2tFKUUGCHLj',
'Ds72joawSKQ9nCDAAmGMKFiwiY6HR7PDzYDHDzZom3tj',
'F1zKr4ZUYo5UAnH1fvYaD6R7ne137NYfS1r5HrCb8NpF',
].sort(getBlockhashComparator()),
).toEqual([
'6JYSQqSHY1E5JDwEfgWMieozqA1KCwiP2cH69to9eWKH',
'7grJ9YUAEHxckLFqCY7fq8cM1UrragNSuPH1dvwJ8EEK',
'7YR1xA7yzFAT4yQCsS4rpowjU1tsh5YUJd9hWMHRppcX',
'AJBPNWCjVLwxff2eJynW56cMRCGmyU4y3vbuvtVdgVgb',
'B8A2zUEDtJjR7nrokNUJYhgUQiwEBzC88rZc6WUE5ZeF',
'BKggsVVp7yLmXtPuBDtC3FXBzvLyyye3Q2tFKUUGCHLj',
'Ds72joawSKQ9nCDAAmGMKFiwiY6HR7PDzYDHDzZom3tj',
'F1zKr4ZUYo5UAnH1fvYaD6R7ne137NYfS1r5HrCb8NpF',
'Ht1VrhoyhwMGMpBBi89BPdJp5R39Mu49suKx3A22W9Qs',
'J9ZSLc9qPg3FR8UqfN6ae1QkVReUmnpLgQqFkGEPqmod',
]);
});
});
});
77 changes: 73 additions & 4 deletions packages/rpc-types/src/blockhash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { Encoder } from '@solana/codecs-core';
import { getBase58Encoder } from '@solana/codecs-strings';
import {
combineCodec,
Decoder,
Encoder,
FixedSizeCodec,
FixedSizeDecoder,
FixedSizeEncoder,
mapEncoder,
} from '@solana/codecs-core';
import { getBase58Decoder, getBase58Encoder, getStringDecoder, getStringEncoder } from '@solana/codecs-strings';
import {
SOLANA_ERROR__BLOCKHASH_STRING_LENGTH_OUT_OF_RANGE,
SOLANA_ERROR__INVALID_BLOCKHASH_BYTE_LENGTH,
Expand All @@ -8,10 +16,40 @@ import {

export type Blockhash = string & { readonly __brand: unique symbol };

let base58Encoder: Encoder<string> | undefined;
let memoizedBase58Encoder: Encoder<string> | undefined;
let memoizedBase58Decoder: Decoder<string> | undefined;

function getMemoizedBase58Encoder(): Encoder<string> {
if (!memoizedBase58Encoder) memoizedBase58Encoder = getBase58Encoder();
return memoizedBase58Encoder;
}

function getMemoizedBase58Decoder(): Decoder<string> {
if (!memoizedBase58Decoder) memoizedBase58Decoder = getBase58Decoder();
return memoizedBase58Decoder;
}

export function isBlockhash(putativeBlockhash: string): putativeBlockhash is Blockhash {
// Fast-path; see if the input string is of an acceptable length.
if (
// Lowest value (32 bytes of zeroes)
putativeBlockhash.length < 32 ||
// Highest value (32 bytes of 255)
putativeBlockhash.length > 44
) {
return false;
}
// Slow-path; actually attempt to decode the input string.
const base58Encoder = getMemoizedBase58Encoder();
const bytes = base58Encoder.encode(putativeBlockhash);
const numBytes = bytes.byteLength;
if (numBytes !== 32) {
return false;
}
return true;
}

export function assertIsBlockhash(putativeBlockhash: string): asserts putativeBlockhash is Blockhash {
if (!base58Encoder) base58Encoder = getBase58Encoder();
// Fast-path; see if the input string is of an acceptable length.
if (
// Lowest value (32 bytes of zeroes)
Expand All @@ -24,6 +62,7 @@ export function assertIsBlockhash(putativeBlockhash: string): asserts putativeBl
});
}
// Slow-path; actually attempt to decode the input string.
const base58Encoder = getMemoizedBase58Encoder();
const bytes = base58Encoder.encode(putativeBlockhash);
const numBytes = bytes.byteLength;
if (numBytes !== 32) {
Expand All @@ -32,3 +71,33 @@ export function assertIsBlockhash(putativeBlockhash: string): asserts putativeBl
});
}
}

export function blockhash(putativeBlockhash: string): Blockhash {
assertIsBlockhash(putativeBlockhash);
return putativeBlockhash as Blockhash;
}

export function getBlockhashEncoder(): FixedSizeEncoder<Blockhash, 32> {
return mapEncoder(getStringEncoder({ encoding: getMemoizedBase58Encoder(), size: 32 }), putativeBlockhash =>
blockhash(putativeBlockhash),
);
}

export function getBlockhashDecoder(): FixedSizeDecoder<Blockhash, 32> {
return getStringDecoder({ encoding: getMemoizedBase58Decoder(), size: 32 }) as FixedSizeDecoder<Blockhash, 32>;
}

export function getBlockhashCodec(): FixedSizeCodec<Blockhash, Blockhash, 32> {
return combineCodec(getBlockhashEncoder(), getBlockhashDecoder());
}

export function getBlockhashComparator(): (x: string, y: string) => number {
return new Intl.Collator('en', {
caseFirst: 'lower',
ignorePunctuation: false,
localeMatcher: 'best fit',
numeric: false,
sensitivity: 'variant',
usage: 'sort',
}).compare;
}

0 comments on commit 19f6725

Please sign in to comment.