From feba5873e7efd47f314f5e22561d0d0e07c26908 Mon Sep 17 00:00:00 2001 From: atticusofsparta Date: Fri, 11 Oct 2024 15:01:02 -0600 Subject: [PATCH] fix(schemas): add zod schemas and tests --- src/common/ant.ts | 56 +++++++++++-- src/types/ant.ts | 178 ++++++++++++++++++++++------------------- tests/unit/ant.test.ts | 151 +++++++++++++++++++++++++++------- 3 files changed, 266 insertions(+), 119 deletions(-) diff --git a/src/common/ant.ts b/src/common/ant.ts index 4ad09ab0..63405911 100644 --- a/src/common/ant.ts +++ b/src/common/ant.ts @@ -86,7 +86,13 @@ export class AoANTReadable implements AoANTRead { const res = await this.process.read({ tags, }); - AntStateSchema.parse(res); + const schemaResult = AntStateSchema.safeParse(res); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT State\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return res; } @@ -95,7 +101,13 @@ export class AoANTReadable implements AoANTRead { const info = await this.process.read({ tags, }); - AntInfoSchema.parse(info); + const schemaResult = AntInfoSchema.safeParse(info); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT Info\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return info; } @@ -117,7 +129,13 @@ export class AoANTReadable implements AoANTRead { const record = await this.process.read({ tags, }); - AntRecordSchema.parse(record); + const schemaResult = AntRecordSchema.safeParse(record); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT Record\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return record; } @@ -134,7 +152,13 @@ export class AoANTReadable implements AoANTRead { const records = await this.process.read>({ tags, }); - AntRecordsSchema.parse(records); + const schemaResult = AntRecordsSchema.safeParse(records); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT Records\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return records; } @@ -164,7 +188,13 @@ export class AoANTReadable implements AoANTRead { const controllers = await this.process.read({ tags, }); - AntControllersSchema.parse(controllers); + const schemaResult = AntControllersSchema.safeParse(controllers); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT Controllers\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return controllers; } @@ -207,7 +237,13 @@ export class AoANTReadable implements AoANTRead { const balances = await this.process.read>({ tags, }); - AntBalancesSchema.parse(balances); + const schemaResult = AntBalancesSchema.safeParse(balances); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT Balances\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return balances; } @@ -228,7 +264,13 @@ export class AoANTReadable implements AoANTRead { const balance = await this.process.read({ tags, }); - z.number().parse(balance); + const schemaResult = z.number().safeParse(balance); + if (!schemaResult.success) { + throw new Error( + 'Invalid ANT Balance\n' + + JSON.stringify(schemaResult.error.format(), null, 2), + ); + } return balance; } } diff --git a/src/types/ant.ts b/src/types/ant.ts index aeda1e99..ae2f799a 100644 --- a/src/types/ant.ts +++ b/src/types/ant.ts @@ -16,38 +16,105 @@ import { z } from 'zod'; import { Logger } from '../common/logger.js'; +import { ARWEAVE_TX_REGEX } from '../constants.js'; import { AoMessageResult, WalletAddress, WriteOptions } from './common.js'; -export type AoANTState = { - Name: string; - Ticker: string; - Denomination: number; - Owner: WalletAddress; - Controllers: WalletAddress[]; - Records: Record; - Balances: Record; - Logo: string; - TotalSupply: number; - Initialized: boolean; - ['Source-Code-TX-ID']: string; -}; +/** + * example error: + * { + "code": "custom", + "message": "Must be an Arweave Transaction ID", + "path": [ + "Records", + "record1", + "transactionId" + ] + }, + */ +export const ArweaveTxIdSchema = z + .string({ + description: 'Arweave Transaction ID', + }) + .refine((val) => ARWEAVE_TX_REGEX.test(val), { + message: 'Must be an Arweave Transaction ID', + }); +export const AntRecordSchema = z.object({ + transactionId: ArweaveTxIdSchema.describe('The Target ID of the undername'), + ttlSeconds: z.number(), +}); +export type AoANTRecord = z.infer; + +export const AntRecordsSchema = z.record(z.string(), AntRecordSchema); +export const AntControllersSchema = z.array( + ArweaveTxIdSchema.describe('Controller address'), +); +export const AntBalancesSchema = z.record( + ArweaveTxIdSchema.describe('Holder address'), + z.number(), +); + +export const AntStateSchema = z.object({ + Name: z.string().describe('The name of the ANT.'), + Ticker: z.string().describe('The ticker symbol for the ANT.'), + Denomination: z.number(), + Owner: ArweaveTxIdSchema.describe('The Owners address.'), + Controllers: AntControllersSchema.describe( + 'Controllers of the ANT who have administrative privileges.', + ), + Records: AntRecordsSchema.describe('Records associated with the ANT.'), + Balances: AntBalancesSchema.describe( + 'Balance details for each address holding the ANT.', + ), + Logo: ArweaveTxIdSchema.describe('Transaction ID of the ANT logo.'), + TotalSupply: z + .number() + .describe('Total supply of the ANT in circulation.') + .min(0, { message: 'Total supply must be a non-negative number' }), + Initialized: z + .boolean() + .describe('Flag indicating whether the ANT has been initialized.'), + ['Source-Code-TX-ID']: ArweaveTxIdSchema.describe( + 'Transaction ID of the Source Code for the ANT.', + ), +}); + +export type AoANTState = z.infer; -export type AoANTInfo = { - Name: string; - Owner: string; - Handlers: string[]; - ['Source-Code-TX-ID']: string; - // token related - Ticker: string; - ['Total-Supply']: string; - Logo: string; - Denomination: string; -}; +export const AntInfoSchema = z.object({ + Name: z.string().describe('The name of the ANT.'), + Owner: ArweaveTxIdSchema.describe('The Owners address.'), + ['Source-Code-TX-ID']: ArweaveTxIdSchema.describe( + 'Transaction ID of the Source Code for the ANT.', + ), + Ticker: z.string().describe('The ticker symbol for the ANT.'), + ['Total-Supply']: z + .number() + .describe('Total supply of the ANT in circulation.') + .min(0, { message: 'Total supply must be a non-negative number' }), + Logo: ArweaveTxIdSchema.describe('Transaction ID of the ANT logo.'), + Denomination: z.number(), +}); -export type AoANTRecord = { - transactionId: string; - ttlSeconds: number; -}; +export type AoANTInfo = z.infer; + +/** + * @param state + * @returns {boolean} + * @throws {z.ZodError} if the state object does not match the expected schema + */ +export function isAoANTState( + state: object, + logger: Logger = Logger.default, +): state is AoANTState { + try { + AntStateSchema.parse(state); + return true; + } catch (error) { + // this allows us to see the path of the error in the object as well as the expected schema on invalid fields + logger.error(error.issues); + return false; + } +} export interface AoANTRead { getState(): Promise; @@ -108,58 +175,3 @@ export interface AoANTWrite extends AoANTRead { options?: WriteOptions, ): Promise; } - -export const AntRecordSchema = z - .object({ - transactionId: z.string(), - ttlSeconds: z.number(), - }) - .passthrough(); -export const AntRecordsSchema = z.record(z.string(), AntRecordSchema); - -export const AntControllersSchema = z.array(z.string()); -export const AntBalancesSchema = z.record(z.string(), z.number()); - -// using passThrough to require the minimum fields and allow others (eg TotalSupply, Logo, etc) -export const AntStateSchema = z - .object({ - Name: z.string(), - Ticker: z.string(), - Owner: z.string(), - Controllers: AntControllersSchema, - Records: AntRecordsSchema, - Balances: AntBalancesSchema, - ['Source-Code-TX-ID']: z.string(), - }) - .passthrough(); - -export const AntInfoSchema = z - .object({ - Name: z.string(), - Owner: z.string(), - ['Source-Code-TX-ID']: z.string(), - Ticker: z.string(), - ['Total-Supply']: z.string(), - Logo: z.string(), - Denomination: z.string(), - }) - .passthrough(); - -/** - * @param state - * @returns {boolean} - * @throws {z.ZodError} if the state object does not match the expected schema - */ -export function isAoANTState( - state: object, - logger: Logger = Logger.default, -): state is AoANTState { - try { - AntStateSchema.parse(state); - return true; - } catch (error) { - // this allows us to see the path of the error in the object as well as the expected schema on invalid fields - logger.error(error.issues); - return false; - } -} diff --git a/tests/unit/ant.test.ts b/tests/unit/ant.test.ts index af666aa5..1cb40741 100644 --- a/tests/unit/ant.test.ts +++ b/tests/unit/ant.test.ts @@ -1,36 +1,129 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import { z } from 'zod'; -import { isAoANTState } from '../../src/types/ant.js'; - -const testAoANTState = { - Name: 'TestToken', - Ticker: 'TST', - Denomination: 1, - Owner: ''.padEnd(43, '1'), - Controllers: [''.padEnd(43, '2')], - Records: { - record1: { - transactionId: ''.padEnd(43, '1'), - ttlSeconds: 3600, - }, - }, - Balances: { - [''.padEnd(43, '1')]: 1, - }, - Logo: ''.padEnd(43, '1'), - TotalSupply: 0, - Initialized: true, - ['Source-Code-TX-ID']: ''.padEnd(43, '1'), -}; -describe('ANT', () => { - it('should validate accurate ANT state', () => { - const res = isAoANTState(testAoANTState); - assert.strictEqual(res, true); +import { + AntInfoSchema, + AntStateSchema, + isAoANTState, +} from '../../src/types/ant.js'; + +const stub_address = 'valid-address'.padEnd(43, '1'); + +describe('ANT Schemas', () => { + it('should validate AntStateSchema', () => { + const validState = { + Name: 'TestToken', + Ticker: 'TST', + Denomination: 0, + Owner: stub_address, + Controllers: [stub_address], + Records: { + record1: { + transactionId: stub_address, + ttlSeconds: 3600, + }, + }, + Balances: { + [stub_address]: 1, + }, + Logo: stub_address, + TotalSupply: 1, + Initialized: true, + ['Source-Code-TX-ID']: stub_address, + }; + const invalidState = { + Name: 'TestToken', + Ticker: 'TST', + Denomination: 0, + Owner: stub_address, + Controllers: [stub_address], + Records: { + record1: { + transactionId: 'invalid-id', + ttlSeconds: '3600', + }, + }, + Balances: { + [stub_address]: 1, + }, + Logo: stub_address, + TotalSupply: -1, + Initialized: true, + ['Source-Code-TX-ID']: stub_address, + }; + + assert.doesNotThrow(() => AntStateSchema.parse(validState)); + assert.throws(() => AntStateSchema.parse(invalidState), z.ZodError); }); - it('should invalidate inaccurate ANT state', () => { - const res = isAoANTState({ ...testAoANTState, Name: 1 }); - assert.strictEqual(res, false); + it('should validate AntInfoSchema', () => { + const validInfo = { + Name: 'TestToken', + Owner: stub_address, + ['Source-Code-TX-ID']: stub_address, + Ticker: 'TST', + ['Total-Supply']: 1, + Logo: stub_address, + Denomination: 0, + }; + const invalidInfo = { + Name: 'TestToken', + Owner: stub_address, + ['Source-Code-TX-ID']: stub_address, + Ticker: 'TST', + ['Total-Supply']: 1000, + Logo: stub_address, + Denomination: '1', + }; + + assert.doesNotThrow(() => AntInfoSchema.parse(validInfo)); + assert.throws(() => AntInfoSchema.parse(invalidInfo), z.ZodError); + }); + + it('should validate isAoANTState', () => { + const validState = { + Name: 'TestToken', + Ticker: 'TST', + Denomination: 0, + Owner: stub_address, + Controllers: [stub_address], + Records: { + record1: { + transactionId: stub_address, + ttlSeconds: 3600, + }, + }, + Balances: { + [stub_address]: 1, + }, + Logo: stub_address, + TotalSupply: 0, + Initialized: true, + ['Source-Code-TX-ID']: stub_address, + }; + const invalidState = { + Name: 'TestToken', + Ticker: 'TST', + Denomination: 0, + Owner: stub_address, + Controllers: [stub_address], + Records: { + record1: { + transactionId: 'invalid-id', + ttlSeconds: '3600', + }, + }, + Balances: { + [stub_address]: 1, + }, + Logo: stub_address, + TotalSupply: -1, + Initialized: true, + ['Source-Code-TX-ID']: stub_address, + }; + + assert.strictEqual(isAoANTState(validState), true); + assert.strictEqual(isAoANTState(invalidState), false); }); });