import { expect, test } from 'vitest'; import { binToHex, generateRandomBytes, hexToBin } from '@bitauth/libauth'; import { COMMIT_IDENTIFIER_BYTE_LENGTH, INVITATION_IDENTIFIER_BYTE_LENGTH, TEMPLATE_IDENTIFIER_BYTE_LENGTH, TOKEN_CATEGORY_BYTE_LENGTH, TRANSACTION_HASH_BYTE_LENGTH, } from '@xo-cash/primitives'; import type { XOInvitation } from '@xo-cash/types'; import { InvitationInvalidError, InvitationJsonMalformedError, InvitationSerializationFailedError, deserializeInvitation, parseInvitation, serializeInvitation, } from '../source/index.ts'; const invitationIdentifier = binToHex(generateRandomBytes(INVITATION_IDENTIFIER_BYTE_LENGTH)); const templateIdentifier = binToHex(generateRandomBytes(TEMPLATE_IDENTIFIER_BYTE_LENGTH)); const commitIdentifier = binToHex(generateRandomBytes(COMMIT_IDENTIFIER_BYTE_LENGTH)); const outpointTransactionHash = binToHex(generateRandomBytes(TRANSACTION_HASH_BYTE_LENGTH)); const tokenCategory = binToHex(generateRandomBytes(TOKEN_CATEGORY_BYTE_LENGTH)); // Valid invitation const buildValidInvitation = (): XOInvitation => { return { invitationIdentifier, createdAtTimestamp: 1700000000000, templateIdentifier, actionIdentifier: 'receive', commits: [ { commitIdentifier, previousCommitIdentifier: undefined, entityIdentifier: 'xpub661MyMwAqRbcEngHLkoUWCKMBMGeqdqCGkqtzrHaTZub9ALw2oRfHA6fQP5n5X9VHStaNTBYomkSb8BFhUGavwD3RG1qvMkEKceTavTp2Tm', createdAtTimestamp: 1700000000000, data: { transactionVersion: 2, transactionLocktime: 0, variables: [{ variableIdentifier: 'amount', value: 18014398509481985n, roleIdentifier: 'sender' }], inputs: [ { outpointTransactionHash: hexToBin(outpointTransactionHash), outpointIndex: 0, sequenceNumber: 0, inputIdentifier: 'fundingInput', roleIdentifier: 'sender', }, ], outputs: [ { outputIdentifier: 'receiveOutput', valueSatoshis: 1000n, lockingBytecode: hexToBin('76a914000000000000000000000000000000000000000088ac'), token: { category: hexToBin(tokenCategory), amount: 21n }, roleIdentifier: 'receiver', }, ], }, signature: '5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b', expiresAtTimestamp: 1700003600000, }, ], }; }; /** * Tests that parseInvitation accepts a valid invitation and returns the same object it received. */ const testParseInvitationReturnsReceivedObject = (): void => { const invitation = buildValidInvitation(); expect(parseInvitation(invitation)).toBe(invitation); }; /** * Tests that an invitation survives a serialize and deserialize round trip. */ const testSerializeInvitationRoundTripsBigintAndBytes = (): void => { const invitation = buildValidInvitation(); const restoredInvitation = deserializeInvitation(serializeInvitation(invitation)); expect(restoredInvitation).toEqual(invitation); const [ restoredCommit ] = restoredInvitation.commits; expect(restoredCommit!.data.variables![0]!.value).toBe(18014398509481985n); expect(restoredCommit!.data.outputs![0]!.valueSatoshis).toBe(1000n); expect(restoredCommit!.data.outputs![0]!.lockingBytecode).toBeInstanceOf(Uint8Array); expect(binToHex(restoredCommit!.data.inputs![0]!.outpointTransactionHash!)).toBe(outpointTransactionHash); }; /** * Tests that parseInvitation throws InvitationInvalidError listing every missing required field. */ const testParseInvitationThrowsOnMissingRequiredFields = (): void => { let thrownError: unknown; try { parseInvitation({}); } catch (caughtError) { thrownError = caughtError; } expect(thrownError).toBeInstanceOf(InvitationInvalidError); const expectedMessage = 'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- invitationIdentifier: expected string, received undefined\n' + '- createdAtTimestamp: expected number, received undefined\n' + '- templateIdentifier: expected string, received undefined\n' + '- actionIdentifier: expected string, received undefined\n' + '- commits: expected array, received undefined'; expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseInvitation throws InvitationInvalidError reporting every field with a wrong type or out of range value. */ const testParseInvitationThrowsOnFieldTypeViolations = (): void => { const invitationWithWrongFieldTypes = { ...buildValidInvitation(), createdAtTimestamp: -1, commits: 'none' }; let thrownError: unknown; try { parseInvitation(invitationWithWrongFieldTypes); } catch (caughtError) { thrownError = caughtError; } expect(thrownError).toBeInstanceOf(InvitationInvalidError); const expectedMessage = 'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- createdAtTimestamp: Too small: expected number to be >=0\n' + '- commits: expected array, received string'; expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseInvitation throws InvitationInvalidError for an unrecognized property at the top level. */ const testParseInvitationThrowsOnUnknownProperties = (): void => { const invitationWithUnknownProperty = { ...buildValidInvitation(), unknownProperty: 'unexpected' }; let thrownError: unknown; try { parseInvitation(invitationWithUnknownProperty); } catch (caughtError) { thrownError = caughtError; } expect(thrownError).toBeInstanceOf(InvitationInvalidError); const expectedMessage = 'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- (root): Unrecognized key: "unknownProperty"'; expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseInvitation throws InvitationInvalidError with the full path of an unrecognized property inside commit data. */ const testParseInvitationThrowsOnDeepUnknownProperties = (): void => { const invitation = buildValidInvitation(); const [ firstCommit ] = invitation.commits; const invitationWithDeepUnknown = { ...invitation, commits: [{ ...firstCommit, data: { ...firstCommit!.data, unknownData: 1 } }] }; let thrownError: unknown; try { parseInvitation(invitationWithDeepUnknown); } catch (caughtError) { thrownError = caughtError; } expect(thrownError).toBeInstanceOf(InvitationInvalidError); const expectedMessage = 'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- commits.0.data: Unrecognized key: "unknownData"'; expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage); }; /** * Tests that deserializeInvitation throws InvitationJsonMalformedError for text that is not valid JSON. */ const testDeserializeInvitationThrowsOnMalformedJson = (): void => { let thrownError: unknown; try { deserializeInvitation('{"invitationIdentifier":'); } catch (caughtError) { thrownError = caughtError; } expect(thrownError).toBeInstanceOf(InvitationJsonMalformedError); const expectedMessage = 'Invitation JSON malformed: expected a valid JSON string, but got: Unexpected end of JSON input'; expect((thrownError as InvitationJsonMalformedError).message).toBe(expectedMessage); expect((thrownError as InvitationJsonMalformedError).reason).toBe('Unexpected end of JSON input'); }; /** * Tests that serializeInvitation throws InvitationSerializationFailedError when the value cannot be written as JSON. */ const testSerializeInvitationThrowsOnCircularValue = (): void => { // A value that refers to itself has no JSON form const circularInvitation: Record = { ...buildValidInvitation() }; circularInvitation.self = circularInvitation; let thrownError: unknown; try { serializeInvitation(circularInvitation as XOInvitation); } catch (caughtError) { thrownError = caughtError; } expect(thrownError).toBeInstanceOf(InvitationSerializationFailedError); const expectedMessage = 'Invitation serialization failed: Converting circular structure to JSON\n' + " --> starting at object with constructor 'Object'\n" + " --- property 'self' closes the circle"; expect((thrownError as InvitationSerializationFailedError).message).toBe(expectedMessage); }; const runTests = async (): Promise => { test('parseInvitation: returns the received object for a valid invitation', testParseInvitationReturnsReceivedObject); test('serializeInvitation: round trips bigint and Uint8Array fields', testSerializeInvitationRoundTripsBigintAndBytes); test('parseInvitation: throws InvitationInvalidError on missing required fields', testParseInvitationThrowsOnMissingRequiredFields); test('parseInvitation: throws InvitationInvalidError on field type violations', testParseInvitationThrowsOnFieldTypeViolations); test('parseInvitation: throws InvitationInvalidError on unknown top-level property', testParseInvitationThrowsOnUnknownProperties); test('parseInvitation: throws InvitationInvalidError on unknown property inside commit data', testParseInvitationThrowsOnDeepUnknownProperties); test('deserializeInvitation: throws InvitationJsonMalformedError on malformed JSON', testDeserializeInvitationThrowsOnMalformedJson); test('serializeInvitation: throws InvitationSerializationFailedError on a circular value', testSerializeInvitationThrowsOnCircularValue); }; await runTests();