Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/rwa-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions src/errors/asset-metadata.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
187 changes: 187 additions & 0 deletions src/rwa/metadata.ts
Original file line number Diff line number Diff line change
@@ -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<string>(RWA_ASSET_STATUSES);
type MetadataField = Exclude<AssetMetadataField, 'metadata'>;

function fail(
field: AssetMetadataField,
code: AssetMetadataErrorCode,
message: string
): never {
throw new AssetMetadataValidationError(message, code, field);
}

function objectInput(input: unknown): Record<string, unknown> {
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<string, unknown>;
}

function ownDataField(
metadata: Record<string, unknown>,
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;
}
},
};

29 changes: 29 additions & 0 deletions src/types/asset-metadata.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading