-
Notifications
You must be signed in to change notification settings - Fork 59
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1427 from input-output-hk/feat/cip20
Feature: CIP20 helper functions + StringUtils
- Loading branch information
Showing
6 changed files
with
204 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]])]]); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './cip20'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
]); | ||
}); | ||
}); | ||
}); |