From e46eb8cbbddc235a4976e87ff971fc1cf9c35f9e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 18:43:07 -0400 Subject: [PATCH 1/6] feat: validate RWA asset metadata --- docs/rwa-metadata.md | 78 +++++++++++++++++++++ src/errors/asset-metadata.ts | 37 ++++++++++ src/index.ts | 3 + src/rwa/metadata.ts | 114 ++++++++++++++++++++++++++++++ src/types/asset-metadata.ts | 29 ++++++++ tests/asset-metadata.test.ts | 123 +++++++++++++++++++++++++++++++++ tests/fixtures/rwa-metadata.ts | 10 +++ 7 files changed, 394 insertions(+) create mode 100644 docs/rwa-metadata.md create mode 100644 src/errors/asset-metadata.ts create mode 100644 src/rwa/metadata.ts create mode 100644 src/types/asset-metadata.ts create mode 100644 tests/asset-metadata.test.ts create mode 100644 tests/fixtures/rwa-metadata.ts diff --git a/docs/rwa-metadata.md b/docs/rwa-metadata.md new file mode 100644 index 0000000..49937ad --- /dev/null +++ b/docs/rwa-metadata.md @@ -0,0 +1,78 @@ +# 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 account-address shape (`G` plus 55 StrKey + base32 characters). This is a structural check; callers that receive + untrusted addresses should additionally perform full StrKey checksum + validation at their transport or signing boundary. +- **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..4edb626 --- /dev/null +++ b/src/rwa/metadata.ts @@ -0,0 +1,114 @@ +import { + AssetMetadataErrorCode, + AssetMetadataField, + AssetMetadataValidationError, +} from '../errors/asset-metadata'; +import { + RWA_ASSET_STATUSES, + RwaAssetMetadata, + RwaAssetStatus, +} from '../types/asset-metadata'; + +const SYMBOL_PATTERN = /^[A-Z0-9]{2,10}(-[A-Z0-9]{2,10})?$/; +const STELLAR_ACCOUNT_PATTERN = /^G[A-Z2-7]{55}$/; +const MAX_I128 = (1n << 127n) - 1n; +const STATUS_SET = new Set(RWA_ASSET_STATUSES); + +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 || Array.isArray(input)) { + return fail('metadata', 'INVALID_METADATA', 'metadata must be an object'); + } + return input as Record; +} + +function issuer(value: unknown): string { + if (typeof value !== 'string' || !STELLAR_ACCOUNT_PATTERN.test(value.trim())) { + return fail('issuer', 'INVALID_ISSUER', 'issuer has an invalid account format'); + } + return value.trim(); +} + +function symbol(value: unknown): string { + if (typeof value !== 'string') { + return fail('symbol', 'INVALID_SYMBOL', 'symbol must be a string'); + } + const normalized = value.trim().toUpperCase(); + if (!SYMBOL_PATTERN.test(normalized)) { + return fail('symbol', 'INVALID_SYMBOL', 'symbol has an invalid format'); + } + return normalized; +} + +function name(value: unknown): string { + if (typeof value !== 'string') { + return fail('name', 'INVALID_NAME', 'name must be a string'); + } + const normalized = value.trim(); + if (normalized.length < 3 || normalized.length > 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' && /^\d+$/.test(value.trim())) { + normalized = BigInt(value.trim()); + } 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(metadata.issuer), + symbol: symbol(metadata.symbol), + name: name(metadata.name), + status: status(metadata.status), + supply: supply(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..f25c53e --- /dev/null +++ b/tests/asset-metadata.test.ts @@ -0,0 +1,123 @@ +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 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 names outside the documented bounds', () => { + const result = rwaAssetMetadataSchema.safeParse({ + ...validRwaAssetMetadataInput, + name: 'x', + }); + + expect(result).toMatchObject({ + success: false, + error: { code: 'INVALID_NAME', field: 'name' }, + }); + }); + + 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('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; From f5a8e1423a803980d7f97919e2472a6f448f0619 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 19:50:45 -0400 Subject: [PATCH 2/6] fix: validate RWA issuer StrKey checksum --- docs/rwa-metadata.md | 7 +++---- src/rwa/metadata.ts | 17 +++++++++++++++-- tests/asset-metadata.test.ts | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/rwa-metadata.md b/docs/rwa-metadata.md index 49937ad..92efff9 100644 --- a/docs/rwa-metadata.md +++ b/docs/rwa-metadata.md @@ -18,10 +18,9 @@ interface RwaAssetMetadata { The schema applies these rules: -- **issuer** — canonical Stellar account-address shape (`G` plus 55 StrKey - base32 characters). This is a structural check; callers that receive - untrusted addresses should additionally perform full StrKey checksum - validation at their transport or signing boundary. +- **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`). diff --git a/src/rwa/metadata.ts b/src/rwa/metadata.ts index 4edb626..5e6eeed 100644 --- a/src/rwa/metadata.ts +++ b/src/rwa/metadata.ts @@ -1,3 +1,4 @@ +import { Keypair } from '@stellar/stellar-sdk'; import { AssetMetadataErrorCode, AssetMetadataField, @@ -30,10 +31,22 @@ function objectInput(input: unknown): Record { } function issuer(value: unknown): string { - if (typeof value !== 'string' || !STELLAR_ACCOUNT_PATTERN.test(value.trim())) { + 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'); } - return value.trim(); + + try { + Keypair.fromPublicKey(normalized); + } catch { + return fail('issuer', 'INVALID_ISSUER', 'issuer has an invalid Stellar StrKey checksum'); + } + + return normalized; } function symbol(value: unknown): string { diff --git a/tests/asset-metadata.test.ts b/tests/asset-metadata.test.ts index f25c53e..ef87a51 100644 --- a/tests/asset-metadata.test.ts +++ b/tests/asset-metadata.test.ts @@ -61,6 +61,21 @@ describe('RWA asset metadata schema', () => { } }); + 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({ From d6018a63840576bfd1124387deb6e28e86ada5ea Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 18:31:37 -0400 Subject: [PATCH 3/6] fix: harden RWA metadata object boundaries --- src/rwa/metadata.ts | 60 +++++++++++++++++++++++++++++++----- tests/asset-metadata.test.ts | 50 +++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/rwa/metadata.ts b/src/rwa/metadata.ts index 5e6eeed..737423f 100644 --- a/src/rwa/metadata.ts +++ b/src/rwa/metadata.ts @@ -14,6 +14,7 @@ const SYMBOL_PATTERN = /^[A-Z0-9]{2,10}(-[A-Z0-9]{2,10})?$/; const STELLAR_ACCOUNT_PATTERN = /^G[A-Z2-7]{55}$/; const MAX_I128 = (1n << 127n) - 1n; const STATUS_SET = new Set(RWA_ASSET_STATUSES); +type MetadataField = Exclude; function fail( field: AssetMetadataField, @@ -24,12 +25,49 @@ function fail( } function objectInput(input: unknown): Record { - if (typeof input !== 'object' || input === null || Array.isArray(input)) { + 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'); @@ -65,7 +103,14 @@ function name(value: unknown): string { return fail('name', 'INVALID_NAME', 'name must be a string'); } const normalized = value.trim(); - if (normalized.length < 3 || normalized.length > 128) { + 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; @@ -100,11 +145,11 @@ function supply(value: unknown): bigint { export function parseRwaAssetMetadata(input: unknown): RwaAssetMetadata { const metadata = objectInput(input); return { - issuer: issuer(metadata.issuer), - symbol: symbol(metadata.symbol), - name: name(metadata.name), - status: status(metadata.status), - supply: supply(metadata.supply), + 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')), }; } @@ -125,3 +170,4 @@ export const rwaAssetMetadataSchema = { } }, }; + diff --git a/tests/asset-metadata.test.ts b/tests/asset-metadata.test.ts index ef87a51..5d2594d 100644 --- a/tests/asset-metadata.test.ts +++ b/tests/asset-metadata.test.ts @@ -99,14 +99,55 @@ describe('RWA asset metadata schema', () => { }); it('rejects names outside the documented bounds', () => { - const result = rwaAssetMetadataSchema.safeParse({ + 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, - name: 'x', + 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); - expect(result).toMatchObject({ + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + expect(rwaAssetMetadataSchema.safeParse(proxy)).toMatchObject({ success: false, - error: { code: 'INVALID_NAME', field: 'name' }, + error: { code: 'INVALID_METADATA', field: 'metadata' }, }); }); @@ -136,3 +177,4 @@ describe('RWA asset metadata schema', () => { ); }); }); + From f373e0d0a985e69677b26c18d241b475f6211977 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 22:43:23 -0400 Subject: [PATCH 4/6] fix: bound RWA supply parsing before BigInt conversion --- src/rwa/metadata.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/rwa/metadata.ts b/src/rwa/metadata.ts index 737423f..d3cf808 100644 --- a/src/rwa/metadata.ts +++ b/src/rwa/metadata.ts @@ -13,6 +13,7 @@ import { const SYMBOL_PATTERN = /^[A-Z0-9]{2,10}(-[A-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; @@ -128,8 +129,21 @@ function supply(value: unknown): bigint { if (typeof value === 'bigint') { normalized = value; - } else if (typeof value === 'string' && /^\d+$/.test(value.trim())) { - normalized = BigInt(value.trim()); + } 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 { From 2f0de8c94e06424476ccfd90340d7da84d9aad03 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 22:43:48 -0400 Subject: [PATCH 5/6] test: reject oversized RWA supply before BigInt parsing --- tests/asset-metadata.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/asset-metadata.test.ts b/tests/asset-metadata.test.ts index 5d2594d..e0c548c 100644 --- a/tests/asset-metadata.test.ts +++ b/tests/asset-metadata.test.ts @@ -171,6 +171,25 @@ describe('RWA asset metadata schema', () => { } }); + 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 From 39aa437f39c813513e97216acae3ec50f4e17ea7 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:58:49 -0400 Subject: [PATCH 6/6] fix(metadata): validate ticker ASCII before canonicalization --- src/rwa/metadata.ts | 8 ++++---- tests/asset-metadata.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/rwa/metadata.ts b/src/rwa/metadata.ts index d3cf808..73602ea 100644 --- a/src/rwa/metadata.ts +++ b/src/rwa/metadata.ts @@ -10,7 +10,7 @@ import { RwaAssetStatus, } from '../types/asset-metadata'; -const SYMBOL_PATTERN = /^[A-Z0-9]{2,10}(-[A-Z0-9]{2,10})?$/; +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(); @@ -92,11 +92,11 @@ function symbol(value: unknown): string { if (typeof value !== 'string') { return fail('symbol', 'INVALID_SYMBOL', 'symbol must be a string'); } - const normalized = value.trim().toUpperCase(); - if (!SYMBOL_PATTERN.test(normalized)) { + const trimmed = value.trim(); + if (!SYMBOL_PATTERN.test(trimmed)) { return fail('symbol', 'INVALID_SYMBOL', 'symbol has an invalid format'); } - return normalized; + return trimmed.toUpperCase(); } function name(value: unknown): string { diff --git a/tests/asset-metadata.test.ts b/tests/asset-metadata.test.ts index e0c548c..d7abb63 100644 --- a/tests/asset-metadata.test.ts +++ b/tests/asset-metadata.test.ts @@ -98,6 +98,28 @@ describe('RWA asset metadata schema', () => { }); }); + + 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)];