diff --git a/docs/rwa-metadata.md b/docs/rwa-metadata.md new file mode 100644 index 0000000..92efff9 --- /dev/null +++ b/docs/rwa-metadata.md @@ -0,0 +1,77 @@ +# RWA metadata validation + +The SDK exposes `rwaAssetMetadataSchema` and `parseRwaAssetMetadata` for +validating RWA metadata before it is passed to contract-integration code or +rendered by a dashboard. + +## Validated shape + +```ts +interface RwaAssetMetadata { + issuer: string; + symbol: string; + name: string; + status: 'active' | 'paused' | 'matured' | 'redeemed' | 'defaulted'; + supply: bigint; +} +``` + +The schema applies these rules: + +- **issuer** — canonical Stellar Ed25519 public-key StrKey (`G` plus 55 + base32 characters) with a valid Stellar checksum. Shape-valid addresses with + a bad StrKey checksum are rejected as `INVALID_ISSUER`. +- **symbol** — normalized to uppercase and follows the existing dashboard + ticker convention: 2-10 letters/digits, optionally followed by one + hyphenated 2-10 character segment (for example `UST-6M`). +- **name** — trimmed and 3-128 characters. +- **status** — one of the issuer-reported lifecycle states already used by the + dashboard. +- **supply** — a non-negative Soroban `i128` value. The parser accepts + `bigint`, an unsigned decimal string, or a safe JavaScript integer and + always returns `bigint`. Unsafe numbers are rejected rather than rounded. + +Invalid input throws `AssetMetadataValidationError` from `parse`. Consumers +that do not want exceptions can use `rwaAssetMetadataSchema.safeParse()` and +branch on its `success` field. Errors expose stable `code` and `field` +values so UI code does not need to parse display text. + +## Dashboard usage + +Validate provider/SDK payloads at the data boundary, then render the returned +`RwaAssetMetadata`. Keep `supply` as `bigint` in application state and +format it for display only at the UI edge. + +```ts +const result = rwaAssetMetadataSchema.safeParse(providerPayload); + +if (!result.success) { + showMetadataError(result.error.code); + return; +} + +renderAsset({ + ...result.data, + supplyLabel: result.data.supply.toLocaleString('en-US'), +}); +``` + +The SDK intentionally shares the dashboard lifecycle vocabulary: +`active`, `paused`, `matured`, `redeemed`, and `defaulted`. This schema +validates the state value only; transition-policy checks belong to the +lifecycle/state-machine layer. + +## Limitations + +A successful parse only establishes that the payload matches this SDK's +protocol/data constraints. It does **not** establish: + +- legal title or ownership of an off-chain asset; +- issuer authority or identity; +- regulatory eligibility or jurisdictional compliance; +- valuation, reserves, solvency, or asset performance; +- authenticity of off-chain documents or provenance. + +Those checks require authoritative systems outside this SDK. Applications +should not present successful metadata validation as legal or compliance +verification. diff --git a/src/errors/asset-metadata.ts b/src/errors/asset-metadata.ts new file mode 100644 index 0000000..bc322a8 --- /dev/null +++ b/src/errors/asset-metadata.ts @@ -0,0 +1,37 @@ +export type AssetMetadataField = + | 'metadata' + | 'issuer' + | 'symbol' + | 'name' + | 'status' + | 'supply'; + +export type AssetMetadataErrorCode = + | 'INVALID_METADATA' + | 'INVALID_ISSUER' + | 'INVALID_SYMBOL' + | 'INVALID_NAME' + | 'INVALID_STATUS' + | 'INVALID_SUPPLY'; + +/** + * Stable validation error for RWA metadata boundaries. + * + * Consumers can branch on `code`/`field` without parsing display text. + */ +export class AssetMetadataValidationError extends Error { + public readonly code: AssetMetadataErrorCode; + public readonly field: AssetMetadataField; + + constructor( + message: string, + code: AssetMetadataErrorCode, + field: AssetMetadataField + ) { + super(message); + this.name = 'AssetMetadataValidationError'; + this.code = code; + this.field = field; + Object.setPrototypeOf(this, AssetMetadataValidationError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index b498dcf..e33672b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,5 +51,8 @@ export * from './errors/network'; export * from './errors/config'; export * from './types/contract-event'; export * from './errors/event'; +export * from './types/asset-metadata'; +export * from './errors/asset-metadata'; +export * from './rwa/metadata'; export type { AegisEnvironmentName, AegisEnvironmentPreset } from './config/environments'; export type { ResolvedAegisConfig } from './config/validate'; diff --git a/src/rwa/metadata.ts b/src/rwa/metadata.ts new file mode 100644 index 0000000..73602ea --- /dev/null +++ b/src/rwa/metadata.ts @@ -0,0 +1,187 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import { + AssetMetadataErrorCode, + AssetMetadataField, + AssetMetadataValidationError, +} from '../errors/asset-metadata'; +import { + RWA_ASSET_STATUSES, + RwaAssetMetadata, + RwaAssetStatus, +} from '../types/asset-metadata'; + +const SYMBOL_PATTERN = /^[A-Za-z0-9]{2,10}(-[A-Za-z0-9]{2,10})?$/; +const STELLAR_ACCOUNT_PATTERN = /^G[A-Z2-7]{55}$/; +const MAX_I128 = (1n << 127n) - 1n; +const MAX_I128_DECIMAL = MAX_I128.toString(); +const STATUS_SET = new Set(RWA_ASSET_STATUSES); +type MetadataField = Exclude; + +function fail( + field: AssetMetadataField, + code: AssetMetadataErrorCode, + message: string +): never { + throw new AssetMetadataValidationError(message, code, field); +} + +function objectInput(input: unknown): Record { + if (typeof input !== 'object' || input === null) { + return fail('metadata', 'INVALID_METADATA', 'metadata must be an object'); + } + + let isArray: boolean; + try { + isArray = Array.isArray(input); + } catch { + return fail('metadata', 'INVALID_METADATA', 'metadata must be an object'); + } + + if (isArray) { + return fail('metadata', 'INVALID_METADATA', 'metadata must be an object'); + } + return input as Record; +} + +function ownDataField( + metadata: Record, + field: MetadataField +): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(metadata, field); + if (!descriptor) { + return undefined; + } + if (!('value' in descriptor)) { + return fail( + 'metadata', + 'INVALID_METADATA', + 'metadata fields must be readable data properties' + ); + } + return descriptor.value; + } catch { + return fail( + 'metadata', + 'INVALID_METADATA', + 'metadata fields must be readable data properties' + ); + } +} + +function issuer(value: unknown): string { + if (typeof value !== 'string') { + return fail('issuer', 'INVALID_ISSUER', 'issuer has an invalid account format'); + } + + const normalized = value.trim(); + if (!STELLAR_ACCOUNT_PATTERN.test(normalized)) { + return fail('issuer', 'INVALID_ISSUER', 'issuer has an invalid account format'); + } + + try { + Keypair.fromPublicKey(normalized); + } catch { + return fail('issuer', 'INVALID_ISSUER', 'issuer has an invalid Stellar StrKey checksum'); + } + + return normalized; +} + +function symbol(value: unknown): string { + if (typeof value !== 'string') { + return fail('symbol', 'INVALID_SYMBOL', 'symbol must be a string'); + } + const trimmed = value.trim(); + if (!SYMBOL_PATTERN.test(trimmed)) { + return fail('symbol', 'INVALID_SYMBOL', 'symbol has an invalid format'); + } + return trimmed.toUpperCase(); +} + +function name(value: unknown): string { + if (typeof value !== 'string') { + return fail('name', 'INVALID_NAME', 'name must be a string'); + } + const normalized = value.trim(); + let characterCount = 0; + for (const _character of normalized) { + characterCount += 1; + if (characterCount > 128) { + break; + } + } + if (characterCount < 3 || characterCount > 128) { + return fail('name', 'INVALID_NAME', 'name must contain 3-128 characters'); + } + return normalized; +} + +function status(value: unknown): RwaAssetStatus { + if (typeof value !== 'string' || !STATUS_SET.has(value)) { + return fail('status', 'INVALID_STATUS', 'status is not recognized'); + } + return value as RwaAssetStatus; +} + +function supply(value: unknown): bigint { + let normalized: bigint; + + if (typeof value === 'bigint') { + normalized = value; + } else if (typeof value === 'string') { + const decimal = value.trim(); + if (!/^\d+$/.test(decimal)) { + return fail('supply', 'INVALID_SUPPLY', 'supply must be an unsigned exact integer'); + } + + const canonicalDecimal = decimal.replace(/^0+(?=\d)/, ''); + if ( + canonicalDecimal.length > MAX_I128_DECIMAL.length || + (canonicalDecimal.length === MAX_I128_DECIMAL.length && + canonicalDecimal > MAX_I128_DECIMAL) + ) { + return fail('supply', 'INVALID_SUPPLY', 'supply is outside the supported i128 range'); + } + normalized = BigInt(canonicalDecimal); + } else if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { + normalized = BigInt(value); + } else { + return fail('supply', 'INVALID_SUPPLY', 'supply must be an unsigned exact integer'); + } + + if (normalized < 0n || normalized > MAX_I128) { + return fail('supply', 'INVALID_SUPPLY', 'supply is outside the supported i128 range'); + } + return normalized; +} + +export function parseRwaAssetMetadata(input: unknown): RwaAssetMetadata { + const metadata = objectInput(input); + return { + issuer: issuer(ownDataField(metadata, 'issuer')), + symbol: symbol(ownDataField(metadata, 'symbol')), + name: name(ownDataField(metadata, 'name')), + status: status(ownDataField(metadata, 'status')), + supply: supply(ownDataField(metadata, 'supply')), + }; +} + +export type RwaAssetMetadataParseResult = + | { success: true; data: RwaAssetMetadata } + | { success: false; error: AssetMetadataValidationError }; + +export const rwaAssetMetadataSchema = { + parse: parseRwaAssetMetadata, + safeParse(input: unknown): RwaAssetMetadataParseResult { + try { + return { success: true, data: parseRwaAssetMetadata(input) }; + } catch (error) { + if (error instanceof AssetMetadataValidationError) { + return { success: false, error }; + } + throw error; + } + }, +}; + diff --git a/src/types/asset-metadata.ts b/src/types/asset-metadata.ts new file mode 100644 index 0000000..339e11b --- /dev/null +++ b/src/types/asset-metadata.ts @@ -0,0 +1,29 @@ +/** + * Issuer-reported lifecycle states for an already-created RWA asset. + * + * These values match the dashboard lifecycle vocabulary. They describe + * protocol/application state only and do not constitute a legal or financial + * determination about the underlying asset. + */ +export const RWA_ASSET_STATUSES = [ + 'active', + 'paused', + 'matured', + 'redeemed', + 'defaulted', +] as const; + +export type RwaAssetStatus = (typeof RWA_ASSET_STATUSES)[number]; + +/** + * Validated RWA metadata shared by SDK, contract-integration and dashboard + * boundaries. Supply is kept as bigint so i128-sized values never pass through + * a lossy JavaScript number. + */ +export interface RwaAssetMetadata { + issuer: string; + symbol: string; + name: string; + status: RwaAssetStatus; + supply: bigint; +} diff --git a/tests/asset-metadata.test.ts b/tests/asset-metadata.test.ts new file mode 100644 index 0000000..d7abb63 --- /dev/null +++ b/tests/asset-metadata.test.ts @@ -0,0 +1,221 @@ +import { + AssetMetadataValidationError, + parseRwaAssetMetadata, + rwaAssetMetadataSchema, +} from '../src'; +import { + VALID_RWA_ISSUER, + validRwaAssetMetadataInput, +} from './fixtures/rwa-metadata'; + +describe('RWA asset metadata schema', () => { + it('normalizes valid metadata without losing supply precision', () => { + const metadata = parseRwaAssetMetadata({ + ...validRwaAssetMetadataInput, + symbol: ' ust-6m ', + }); + + expect(metadata).toEqual({ + ...validRwaAssetMetadataInput, + symbol: 'UST-6M', + supply: 1000000000000n, + }); + }); + + it('accepts zero and the maximum non-negative i128 supply', () => { + const maxI128 = (1n << 127n) - 1n; + + expect( + parseRwaAssetMetadata({ + ...validRwaAssetMetadataInput, + supply: 0n, + }).supply + ).toBe(0n); + + expect( + parseRwaAssetMetadata({ + ...validRwaAssetMetadataInput, + supply: maxI128.toString(), + }).supply + ).toBe(maxI128); + }); + + it('returns a typed issuer error for an invalid account', () => { + expect(() => + parseRwaAssetMetadata({ + ...validRwaAssetMetadataInput, + issuer: 'not-a-stellar-address', + }) + ).toThrow(AssetMetadataValidationError); + + try { + parseRwaAssetMetadata({ + ...validRwaAssetMetadataInput, + issuer: 'not-a-stellar-address', + }); + } catch (error) { + expect(error).toMatchObject({ + code: 'INVALID_ISSUER', + field: 'issuer', + }); + } + }); + + it('rejects a shape-valid issuer with an invalid StrKey checksum', () => { + const badChecksumIssuer = `${VALID_RWA_ISSUER.slice(0, -1)}A`; + + expect(badChecksumIssuer).toMatch(/^G[A-Z2-7]{55}$/); + expect( + rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + issuer: badChecksumIssuer, + }) + ).toMatchObject({ + success: false, + error: { code: 'INVALID_ISSUER', field: 'issuer' }, + }); + }); + + it('rejects malformed symbols and unknown lifecycle states', () => { + expect( + rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + symbol: 'A', + }) + ).toMatchObject({ + success: false, + error: { code: 'INVALID_SYMBOL', field: 'symbol' }, + }); + + expect( + rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + status: 'approved', + }) + ).toMatchObject({ + success: false, + error: { code: 'INVALID_STATUS', field: 'status' }, + }); + }); + + + it('rejects non-ASCII ticker lookalikes before canonicalization', () => { + for (const symbol of ['ß', 'ıUSD', 'uſd']) { + expect( + rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + symbol, + }) + ).toMatchObject({ + success: false, + error: { code: 'INVALID_SYMBOL', field: 'symbol' }, + }); + } + + expect( + parseRwaAssetMetadata({ + ...validRwaAssetMetadataInput, + symbol: 'usd', + }).symbol + ).toBe('USD'); + }); + + it('rejects names outside the documented bounds', () => { + const invalidNames = ['x', '😀😀', '😀'.repeat(129)]; + + for (const name of invalidNames) { + const result = rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + name, + }); + + expect(result).toMatchObject({ + success: false, + error: { code: 'INVALID_NAME', field: 'name' }, + }); + } + + expect( + rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + name: '😀'.repeat(65), + }).success + ).toBe(true); + }); + + it('reads only own data properties and keeps safeParse exception-free', () => { + const inherited = Object.create(validRwaAssetMetadataInput); + expect(rwaAssetMetadataSchema.safeParse(inherited)).toMatchObject({ + success: false, + error: { code: 'INVALID_ISSUER', field: 'issuer' }, + }); + + let getterCalls = 0; + const accessorMetadata = { + ...validRwaAssetMetadataInput, + get issuer(): string { + getterCalls += 1; + throw new Error('metadata accessors must not execute'); + }, + }; + + expect(rwaAssetMetadataSchema.safeParse(accessorMetadata)).toMatchObject({ + success: false, + error: { code: 'INVALID_METADATA', field: 'metadata' }, + }); + expect(getterCalls).toBe(0); + + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + expect(rwaAssetMetadataSchema.safeParse(proxy)).toMatchObject({ + success: false, + error: { code: 'INVALID_METADATA', field: 'metadata' }, + }); + }); + + it('rejects negative, overflowed, and precision-losing supply inputs', () => { + const invalidSupplies = [ + '-1', + (1n << 127n).toString(), + Number.MAX_SAFE_INTEGER + 1, + ]; + + for (const supply of invalidSupplies) { + const result = rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + supply, + }); + + expect(result).toMatchObject({ + success: false, + error: { code: 'INVALID_SUPPLY', field: 'supply' }, + }); + } + }); + + it('rejects oversized decimal supply before BigInt conversion', () => { + const bigIntSpy = jest.spyOn(globalThis, 'BigInt'); + + try { + const result = rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + supply: '9'.repeat(10_000), + }); + + expect(bigIntSpy).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: false, + error: { code: 'INVALID_SUPPLY', field: 'supply' }, + }); + } finally { + bigIntSpy.mockRestore(); + } + }); + + it('preserves a valid issuer verbatim', () => { + expect(parseRwaAssetMetadata(validRwaAssetMetadataInput).issuer).toBe( + VALID_RWA_ISSUER + ); + }); +}); + diff --git a/tests/fixtures/rwa-metadata.ts b/tests/fixtures/rwa-metadata.ts new file mode 100644 index 0000000..77c33c8 --- /dev/null +++ b/tests/fixtures/rwa-metadata.ts @@ -0,0 +1,10 @@ +export const VALID_RWA_ISSUER = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +export const validRwaAssetMetadataInput = { + issuer: VALID_RWA_ISSUER, + symbol: 'UST-6M', + name: 'Tokenized Treasury Fund', + status: 'active', + supply: '1000000000000', +} as const;