Skip to content

Commit

Permalink
Merge pull request #1427 from input-output-hk/feat/cip20
Browse files Browse the repository at this point in the history
Feature: CIP20 helper functions + StringUtils
  • Loading branch information
rhyslbw authored Aug 21, 2024
2 parents b265866 + 8bc200a commit 0d5586a
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 0 deletions.
65 changes: 65 additions & 0 deletions packages/core/src/TxMetadata/cip20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { CustomError } from 'ts-custom-error';
import { StringUtils } from '@cardano-sdk/util';
import { TxMetadata } from '../Cardano';

export const CIP_20_METADATUM_LABEL = 674n;
const MAX_BYTES = 64;

export enum MessageValidationFailure {
wrongType = 'WrongType',
oversize = 'Oversize'
}

export type MessageValidationResult = {
valid: boolean;
failure?: MessageValidationFailure;
};

export type CIP20TxMetadataMessage = string;

export type CIP20TxMetadataArgs = {
// An array of message strings, limited to 64 bytes each
messages: CIP20TxMetadataMessage[];
};

export type ValidationResultMap = Map<CIP20TxMetadataMessage, MessageValidationResult>;

export class MessageValidationError extends CustomError {
public constructor(failures: ValidationResultMap) {
const m = [failures.entries()].map(
([[message, result]]) => `${result.failure}: ${message.slice(0, Math.max(0, MAX_BYTES + 1))}`
);
super(`The provided message array contains validation errors | ${m}`);
}
}

/**
* Validate each message for correct type, in the case of JavaScript, and size constraint
*
* @param entry unknown
* @returns Validation result
*/
export const validateMessage = (entry: unknown): MessageValidationResult => {
if (typeof entry !== 'string') return { failure: MessageValidationFailure.wrongType, valid: false };
if (StringUtils.byteSize(entry) > MAX_BYTES) return { failure: MessageValidationFailure.oversize, valid: false };
return { valid: true };
};

/**
* Converts an object containing an array of individual messages into https://cips.cardano.org/cip/CIP-20 compliant
* transaction metadata
*
* @param args CIP20TxMetadataArgs or a string to be transformed into an array
* @returns CIP20-compliant transaction metadata
* @throws Message validation error containing details. Use validateMessage to independently check each message before calling this function
*/
export const toCIP20Metadata = (args: CIP20TxMetadataArgs | string): TxMetadata => {
const messages = typeof args === 'string' ? StringUtils.chunkByBytes(args, MAX_BYTES) : args.messages;
const invalidMessages: ValidationResultMap = new Map();
for (const message of messages) {
const result = validateMessage(message);
if (!result.valid) invalidMessages.set(message, result);
}
if (invalidMessages.size > 0) throw new MessageValidationError(invalidMessages);
return new Map([[CIP_20_METADATUM_LABEL, new Map([['msg', messages]])]]);
};
1 change: 1 addition & 0 deletions packages/core/src/TxMetadata/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './cip20';
81 changes: 81 additions & 0 deletions packages/core/test/TxMetadata/cip20.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
CIP_20_METADATUM_LABEL,
MessageValidationError,
MessageValidationFailure,
toCIP20Metadata,
validateMessage
} from '../../src/TxMetadata';
import { Cardano } from '../../src';
import { TxMetadata } from '../../src/Cardano';

describe('TxMetadata.cip20', () => {
const compliantShortMessage = 'Lorem ipsum dolor';
const compliantMaxMessage = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean';
const oversizeMessage = `${compliantMaxMessage}1`;
describe('validateMessage', () => {
it('validates a CIP-20 message if a string and less than or equal to 64 bytes', () => {
expect(validateMessage(compliantShortMessage)).toStrictEqual({ valid: true });
expect(validateMessage(compliantMaxMessage)).toStrictEqual({ valid: true });
});
it('invalidates a CIP-20 message if a string but over 64 bytes', () => {
expect(validateMessage(oversizeMessage)).toStrictEqual({
failure: MessageValidationFailure.oversize,
valid: false
});
});
it('invalidates a CIP-20 message if wrong type', () => {
expect(validateMessage(1 as unknown as string)).toStrictEqual({
failure: MessageValidationFailure.wrongType,
valid: false
});
expect(validateMessage({ message: compliantShortMessage } as unknown as string)).toStrictEqual({
failure: MessageValidationFailure.wrongType,
valid: false
});
expect(validateMessage([compliantShortMessage] as unknown as string)).toStrictEqual({
failure: MessageValidationFailure.wrongType,
valid: false
});
expect(
validateMessage(new Map([[CIP_20_METADATUM_LABEL, compliantShortMessage]]) as unknown as string)
).toStrictEqual({ failure: MessageValidationFailure.wrongType, valid: false });
});
});
describe('toCIP20Metadata', () => {
describe('args object', () => {
it('produces a CIP-20-compliant TxMetadata map', () => {
const metadata = toCIP20Metadata({ messages: [compliantShortMessage] }) as TxMetadata;
expect(metadata.has(CIP_20_METADATUM_LABEL)).toBe(true);
const cip20Metadata = metadata.get(CIP_20_METADATUM_LABEL) as Cardano.MetadatumMap;
expect(cip20Metadata.get('msg')).toStrictEqual([compliantShortMessage]);
});
it('throws an error if any messages are invalid', () => {
expect(() =>
toCIP20Metadata({
messages: [compliantShortMessage, compliantMaxMessage, oversizeMessage]
})
).toThrowError(MessageValidationError);
});
});
describe('producing a CIP-20-compliant TxMetadata map with a string arg', () => {
test('larger than 64 bytes', () => {
const metadata = toCIP20Metadata(oversizeMessage) as TxMetadata;
expect(metadata.has(CIP_20_METADATUM_LABEL)).toBe(true);
const cip20Metadata = metadata.get(CIP_20_METADATUM_LABEL) as Cardano.MetadatumMap;
expect((cip20Metadata.get('msg') as string[]).length).toBe(2);
});
test('equal to 64 bytes', () => {
const metadata = toCIP20Metadata(compliantMaxMessage) as TxMetadata;
expect(metadata.has(CIP_20_METADATUM_LABEL)).toBe(true);
const cip20Metadata = metadata.get(CIP_20_METADATUM_LABEL) as Cardano.MetadatumMap;
expect((cip20Metadata.get('msg') as string[]).length).toBe(1);
});
test('smaller than to 64 bytes', () => {
const metadata = toCIP20Metadata(compliantShortMessage) as TxMetadata;
expect(metadata.has(CIP_20_METADATUM_LABEL)).toBe(true);
const cip20Metadata = metadata.get(CIP_20_METADATUM_LABEL) as Cardano.MetadatumMap;
expect((cip20Metadata.get('msg') as string[]).length).toBe(1);
});
});
});
});
1 change: 1 addition & 0 deletions packages/util/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './opaqueTypes';
export * from './environment';
export * from './patchObject';
export * from './isPromise';
export * from './string';
export * from './transformer';
export * from './Percent';
export * from './util';
32 changes: 32 additions & 0 deletions packages/util/src/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const StringUtils = {
byteSize: (str: string) => new Blob([str]).size,
/**
* Creates an array of strings split into groups, limited to the specified size. If array can't be split evenly, the final chunk will be the remaining.
*
* @param str The string to chunk
* @param maxBytes number of bytes to impose as a limit
* @returns Array of string chunks
*/
chunkByBytes: (str: string, maxBytes: number): string[] => {
let chunkSize = 0;
let chunkStart = 0;
const result = [];

for (let char = 0; char < str.length; char++) {
const isEndOfArray = char === str.length - 1;
const currentChunkSize = chunkSize + StringUtils.byteSize(str[char]);
const nextCharSize = !isEndOfArray ? currentChunkSize + StringUtils.byteSize(str[char + 1]) : maxBytes + 1;
const atLimit = currentChunkSize === maxBytes || nextCharSize > maxBytes || isEndOfArray;

if (atLimit) {
const chunk = str.slice(chunkStart, char + 1);
result.push(chunk);
chunkStart = char + 1;
chunkSize = 0;
} else {
chunkSize = currentChunkSize;
}
}
return result;
}
};
24 changes: 24 additions & 0 deletions packages/util/test/string.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StringUtils } from '../src';

// Test vectors sourced from https://www.javainuse.com/bytesize

describe('StringUtils', () => {
describe('byteSize', () => {
it('returns the byte size of the string', () => {
expect(StringUtils.byteSize('The quick brown fox jumps over the lazy dog')).toEqual(43);
expect(StringUtils.byteSize('helloWorld!')).toEqual(11);
expect(StringUtils.byteSize('👋')).toEqual(4);
});
});
describe('sliceByBytes', () => {
it('slices the string into an array, limiting each substring to the specified bytes', () => {
expect(StringUtils.chunkByBytes('The quick brown fox jumps over the lazy dog', 10)).toEqual([
'The quick ',
'brown fox ',
'jumps over',
' the lazy ',
'dog'
]);
});
});
});

0 comments on commit 0d5586a

Please sign in to comment.