Skip to content
Open
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
23 changes: 13 additions & 10 deletions platform-js/platform-js/src/effect/internal/hex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,20 @@ export const parseHex: (source: string) => Either.Either<Hex.ParsedHexString, Pa
[NodeInspectSymbol]: () => match.groups
};
if (parsedHex.incompleteChars) {
if (parsedHex.incompleteChars.length % 2 > 0) {
return Either.left(ParseError.make(`Last byte of source string '${source}' is incomplete`, source, parsedHex));
const offset = parsedHex.byteChars.length + (parsedHex.hasPrefix ? 2 : 0);
for (let i = 0; i < parsedHex.incompleteChars.length; i++) {
if (!/[0-9A-Fa-f]/.test(parsedHex.incompleteChars[i])) {
const invalidCharPos = offset + i;
return Either.left(
ParseError.make(
`Invalid hex-digit '${source[invalidCharPos]}' found in source string at index ${invalidCharPos}`,
source,
parsedHex
)
);
}
}
const invalidCharPos = parsedHex.byteChars.length + (parsedHex.hasPrefix ? 2 : 0);
return Either.left(
ParseError.make(
`Invalid hex-digit '${source[invalidCharPos]}' found in source string at index ${invalidCharPos}`,
source,
parsedHex
)
);
return Either.left(ParseError.make(`Last byte of source string '${source}' is incomplete`, source, parsedHex));
}
if (!parsedHex.byteChars) {
return Either.left(ParseError.make(`Source string '${source}' is not a valid hex-string`, source, parsedHex));
Expand Down
69 changes: 69 additions & 0 deletions platform-js/platform-js/test/effect/DomainSeparator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* This file is part of midnight-sdk.
* Copyright (C) 2025 Midnight Foundation
* SPDX-License-Identifier: Apache-2.0
* Licensed under the Apache License, Version 2.0 (the "License");
* You may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, expect, it } from '@effect/vitest';
import * as DomainSeparator from '@midnight-ntwrk/platform-js/effect/DomainSeparator';
import * as fc from 'effect/FastCheck';

import * as Arbitrary from './Arbitrary.js';

describe('DomainSeparator', () => {
it('should accept exactly 32-byte plain hex strings', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('32..=32'), (hex) => {
expect(() => DomainSeparator.DomainSeparator(hex)).not.toThrowError();
})
));

it('should reject plain hex strings shorter than 32 bytes', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('2..32'), (hex) => {
expect(() => DomainSeparator.DomainSeparator(hex)).toThrowError();
})
));

it('should reject plain hex strings longer than 32 bytes', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('33..=50'), (hex) => {
expect(() => DomainSeparator.DomainSeparator(hex)).toThrowError();
})
));

it('should reject prefixed hex strings', () => fc.assert(
fc.property(Arbitrary.makePrefixedHexArbitrary('32..=32'), (hex) => {
expect(() => DomainSeparator.DomainSeparator(hex)).toThrowError();
})
));

it('should reject non-hex strings', () => {
expect(() => DomainSeparator.DomainSeparator('not-a-hex-string')).toThrowError();
});

describe('asBytes', () => {
it('should return a Uint8Array of exactly 32 bytes', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('32..=32'), (hex) => {
const separator = DomainSeparator.DomainSeparator(hex);
const bytes = DomainSeparator.asBytes(separator);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(32);
})
));

it('should round-trip through hex encoding', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('32..=32'), (hex) => {
const separator = DomainSeparator.DomainSeparator(hex);
const bytes = DomainSeparator.asBytes(separator);
expect(Buffer.from(bytes).toString('hex')).toBe(hex.toLowerCase());
})
));
});
});
29 changes: 29 additions & 0 deletions platform-js/platform-js/test/effect/Hex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,35 @@ describe('Hex', () => {
message: 'Invalid hex-digit \'H\' found in source string at index 0'
});
}));

it.effect('should report the invalid character position when valid hex precedes it in incompleteChars', () => Effect.gen(function* () {
// "0xaG": prefix="0x", byteChars="" (pair "aG" is not valid), incompleteChars="aG"
// The invalid char is 'G' at index 3, not 'a' at index 2.
const error = yield* Effect.flip(Hex.parseHex('0xaG'));
expect(ParseError.isParseError(error)).toBeTruthy();
expect(error).toMatchObject({
message: 'Invalid hex-digit \'G\' found in source string at index 3'
});
}));

it.effect('should report invalid character instead of incomplete byte when trailing char is not hex', () => Effect.gen(function* () {
// "abX": byteChars="ab", incompleteChars="X"
// The trailing 'X' is not a hex digit — should not say "last byte is incomplete".
const error = yield* Effect.flip(Hex.parseHex('abX'));
expect(ParseError.isParseError(error)).toBeTruthy();
expect(error).toMatchObject({
message: 'Invalid hex-digit \'X\' found in source string at index 2'
});
}));

it.effect('should report incomplete byte when trailing char is a valid hex digit', () => Effect.gen(function* () {
// "abc": byteChars="ab", incompleteChars="c" — 'c' is valid hex, so the byte is incomplete
const error = yield* Effect.flip(Hex.parseHex('abc'));
expect(ParseError.isParseError(error)).toBeTruthy();
expect(error).toMatchObject({
message: 'Last byte of source string \'abc\' is incomplete'
});
}));
});

describe('PlainHex', () => {
Expand Down
59 changes: 57 additions & 2 deletions platform-js/platform-js/test/effect/NetworkId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* limitations under the License.
*/

import { describe, expect,it } from '@effect/vitest';
import { describe, expect, it } from '@effect/vitest';
import * as NetworkId from '@midnight-ntwrk/platform-js/effect/NetworkId';
import * as fc from 'effect/FastCheck';

Expand All @@ -24,14 +24,27 @@ describe('NetworkId', () => {
it('should return true for MainNet', () => {
expect(NetworkId.MainNet.isMainNet()).toBe(true);
});

it('should return false for any named network identifier', () => fc.assert(
fc.property(Arbitrary.makeNetworkIdArbitrary(), (networkId) => {
expect(NetworkId.make(networkId).isMainNet()).toBe(false);
})
));
});

describe('make', () => {
it('should preserve MainNet identity when wrapping an existing NetworkId', () => {
const wrapped = NetworkId.make(NetworkId.MainNet);
expect(wrapped.isMainNet()).toBe(true);
});

it('should preserve the moniker when wrapping an existing named NetworkId', () => {
const original = NetworkId.make('hosky-dev01');
const wrapped = NetworkId.make(original);
expect(NetworkId.equals(original, wrapped)).toBe(true);
});
});

describe('equals', () => {
it('should return true for two network identifiers equal by name', () => {
const a = NetworkId.make('hosky-dev01');
Expand All @@ -46,5 +59,47 @@ describe('NetworkId', () => {

expect(NetworkId.equals(a, b)).toBe(false);
});

it('should return true for MainNet compared to another MainNet', () => {
expect(NetworkId.equals(NetworkId.MainNet, NetworkId.make(NetworkId.MainNet))).toBe(true);
});

it('should return false when comparing MainNet with a named network', () => {
expect(NetworkId.equals(NetworkId.MainNet, NetworkId.make('preview'))).toBe(false);
});
});

describe('isNetworkId', () => {
it('should return true for a NetworkId instance', () => {
expect(NetworkId.isNetworkId(NetworkId.MainNet)).toBe(true);
expect(NetworkId.isNetworkId(NetworkId.make('test-preview'))).toBe(true);
});

it('should return false for non-NetworkId values', () => {
expect(NetworkId.isNetworkId(null)).toBe(false);
expect(NetworkId.isNetworkId('main')).toBe(false);
expect(NetworkId.isNetworkId(42)).toBe(false);
expect(NetworkId.isNetworkId({})).toBe(false);
});
});

describe('toString', () => {
it('should return the moniker string for a named network', () => {
expect(NetworkId.make('hosky-dev01').toString()).toBe('hosky-dev01');
});

it('should return the MainNet moniker string for MainNet', () => {
expect(NetworkId.MainNet.toString()).toBe('main');
});
});

describe('toJSON', () => {
it('should include the moniker in the JSON representation', () => {
expect(NetworkId.make('hosky-dev01').toJSON()).toMatchObject({ _id: 'NetworkId', moniker: 'hosky-dev01' });
});

it('should include the MainNet moniker in the JSON representation for MainNet', () => {
expect(NetworkId.MainNet.toJSON()).toMatchObject({ _id: 'NetworkId', moniker: 'main' });
});
});
});
68 changes: 68 additions & 0 deletions platform-js/platform-js/test/effect/SigningKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* This file is part of midnight-sdk.
* Copyright (C) 2025 Midnight Foundation
* SPDX-License-Identifier: Apache-2.0
* Licensed under the Apache License, Version 2.0 (the "License");
* You may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, expect, it } from '@effect/vitest';
import * as SigningKey from '@midnight-ntwrk/platform-js/effect/SigningKey';
import * as fc from 'effect/FastCheck';

import * as Arbitrary from './Arbitrary.js';

describe('SigningKey', () => {
it('should accept 32-byte plain hex strings (raw BIP-340 key)', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('32..=32'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).not.toThrowError();
})
));

it('should accept 33-byte plain hex strings (key with 1-byte version prefix)', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('33..=33'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).not.toThrowError();
})
));

it('should accept 34-byte plain hex strings (key with 2-byte version prefix)', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('34..=34'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).not.toThrowError();
})
));

it('should accept 35-byte plain hex strings (key with 3-byte version prefix)', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('35..=35'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).not.toThrowError();
})
));

it('should reject plain hex strings shorter than 32 bytes', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('2..32'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).toThrowError();
})
));

it('should reject plain hex strings longer than 35 bytes', () => fc.assert(
fc.property(Arbitrary.makePlainHexArbitrary('36..=50'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).toThrowError();
})
));

it('should reject prefixed hex strings', () => fc.assert(
fc.property(Arbitrary.makePrefixedHexArbitrary('32..=35'), (hex) => {
expect(() => SigningKey.SigningKey(hex)).toThrowError();
})
));

it('should reject non-hex strings', () => {
expect(() => SigningKey.SigningKey('not-a-valid-signing-key')).toThrowError();
});
});