Skip to content
Merged
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
113 changes: 113 additions & 0 deletions src/assets/eligibility/stellar/__tests__/validator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Stellar Asset Eligibility Validator — Tests (Issue #447)
*/

import { StellarAssetEligibilityValidator } from '../index';

// Well-formed Stellar issuer strkeys: 'G' + 55 base32 chars [A-Z2-7].
const ISSUER = 'G' + 'A'.repeat(55);
const OTHER_ISSUER = 'G' + 'B'.repeat(55);

describe('StellarAssetEligibilityValidator', () => {
describe('eligible assets', () => {
it('accepts native XLM by default', () => {
const v = new StellarAssetEligibilityValidator();
const result = v.validate('XLM');
expect(result.eligible).toBe(true);
expect(result.status).toBe('eligible');
expect(result.issues).toHaveLength(0);
});

it('treats "native" as XLM', () => {
expect(new StellarAssetEligibilityValidator().isEligible('native')).toBe(true);
});

it('accepts a supported asset with a valid issuer', () => {
const v = new StellarAssetEligibilityValidator();
const result = v.validate(`USDC:${ISSUER}`);
expect(result.eligible).toBe(true);
expect(result.status).toBe('eligible');
expect(result.asset).toEqual({ code: 'USDC', issuer: ISSUER });
});
});

describe('unsupported assets are rejected', () => {
it('rejects an unlisted asset by default', () => {
const v = new StellarAssetEligibilityValidator();
const result = v.validate(`FOO:${ISSUER}`);
expect(result.eligible).toBe(false);
expect(result.status).toBe('unsupported');
expect(result.issues[0].code).toBe('ASSET_UNSUPPORTED');
});

it('allows unlisted assets only when configured to', () => {
const v = new StellarAssetEligibilityValidator({ allowUnlistedAssets: true });
expect(v.isEligible(`FOO:${ISSUER}`)).toBe(true);
});

it('rejects native XLM when allowNative is false', () => {
const v = new StellarAssetEligibilityValidator({ allowNative: false });
const result = v.validate('XLM');
expect(result.eligible).toBe(false);
expect(result.status).toBe('unsupported');
expect(result.issues[0].code).toBe('NATIVE_NOT_ELIGIBLE');
});
});

describe('restricted assets are detected', () => {
it('rejects a restricted asset code (takes precedence over support)', () => {
const v = new StellarAssetEligibilityValidator({ restrictedAssetCodes: ['USDC'] });
const result = v.validate(`USDC:${ISSUER}`);
expect(result.eligible).toBe(false);
expect(result.status).toBe('restricted');
expect(result.issues.map((i) => i.code)).toContain('ASSET_RESTRICTED');
});

it('rejects a restricted issuer', () => {
const v = new StellarAssetEligibilityValidator({ restrictedIssuers: [ISSUER] });
const result = v.validate(`USDC:${ISSUER}`);
expect(result.status).toBe('restricted');
expect(result.issues.map((i) => i.code)).toContain('ISSUER_RESTRICTED');
});
});

describe('invalid identifiers', () => {
it('rejects an unparseable identifier', () => {
const v = new StellarAssetEligibilityValidator();
const result = v.validate('USDC:not-a-valid-issuer');
expect(result.eligible).toBe(false);
expect(result.status).toBe('invalid');
expect(result.issues[0].code).toBe('INVALID_ASSET_FORMAT');
expect(result.asset).toBeNull();
});

it('rejects a non-native asset missing an issuer when required', () => {
const v = new StellarAssetEligibilityValidator();
const result = v.validate('USDC');
expect(result.eligible).toBe(false);
expect(result.status).toBe('invalid');
expect(result.issues[0].code).toBe('MISSING_ISSUER');
});

it('allows a non-native asset without issuer when not required', () => {
const v = new StellarAssetEligibilityValidator({ requireIssuerForNonNative: false });
expect(v.isEligible('USDC')).toBe(true);
});
});

describe('batch helpers', () => {
it('filterEligible keeps only eligible identifiers', () => {
const v = new StellarAssetEligibilityValidator();
const candidates = ['XLM', `USDC:${ISSUER}`, `FOO:${OTHER_ISSUER}`, 'USDC'];
expect(v.filterEligible(candidates)).toEqual(['XLM', `USDC:${ISSUER}`]);
});

it('validateMany returns a result per input', () => {
const v = new StellarAssetEligibilityValidator();
const results = v.validateMany(['XLM', `FOO:${ISSUER}`]);
expect(results).toHaveLength(2);
expect(results[0].eligible).toBe(true);
expect(results[1].eligible).toBe(false);
});
});
});
19 changes: 19 additions & 0 deletions src/assets/eligibility/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Stellar Asset Eligibility Validator (Issue #447)
*
* @example
* ```ts
* import { StellarAssetEligibilityValidator } from 'src/assets/eligibility/stellar';
*
* const validator = new StellarAssetEligibilityValidator({
* supportedAssetCodes: ['USDC', 'USDT'],
* restrictedIssuers: ['GBADISSUER...'],
* });
*
* validator.isEligible('USDC:GA5Z...'); // true
* validator.filterEligible(candidateAssets); // drop unsupported/restricted
* ```
*/

export * from './types';
export { StellarAssetEligibilityValidator } from './validator';
55 changes: 55 additions & 0 deletions src/assets/eligibility/stellar/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Stellar Asset Eligibility Validator — Types (Issue #447)
*
* Determines whether a Stellar asset is eligible to be bridged at all — i.e.
* the asset itself is well-formed, supported, and not restricted — independent
* of any specific source/target chain pair (that is the bridgeability
* checker's job). Used to keep unsupported/restricted assets out of route
* searches.
*/

import { StellarAsset } from '../../../scanning/assets/compatibility/stellar/asset-compatibility-scanner.types';

export type { StellarAsset };

/** Outcome category for an eligibility check. */
export type EligibilityStatus = 'eligible' | 'unsupported' | 'restricted' | 'invalid';

/** A problem found while validating eligibility. */
export interface EligibilityIssue {
severity: 'error' | 'warning';
code: string;
message: string;
}

/** Result of validating a single asset's eligibility. */
export interface AssetEligibilityResult {
/** True only when the asset is well-formed, supported, and not restricted. */
eligible: boolean;
status: EligibilityStatus;
/** The original identifier that was validated. */
assetIdentifier: string;
/** Parsed asset (null when the identifier could not be parsed). */
asset: StellarAsset | null;
issues: EligibilityIssue[];
checkedAt: number;
}

/** Configuration for {@link AssetEligibilityConfig}-driven validation. */
export interface AssetEligibilityConfig {
/** Asset codes explicitly supported for bridging (in addition to native XLM). */
supportedAssetCodes: string[];
/** Asset codes that are restricted/disallowed even if otherwise supported. */
restrictedAssetCodes: string[];
/** Issuer addresses that are restricted/blocked. */
restrictedIssuers: string[];
/** Whether the native asset (XLM) is eligible. Default: true. */
allowNative: boolean;
/**
* Whether assets not present in `supportedAssetCodes` are allowed.
* Default: false — unknown assets are rejected as unsupported.
*/
allowUnlistedAssets: boolean;
/** Require non-native assets to declare an issuer. Default: true. */
requireIssuerForNonNative: boolean;
}
195 changes: 195 additions & 0 deletions src/assets/eligibility/stellar/validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Stellar Asset Eligibility Validator (Issue #447)
*
* Validates whether a Stellar asset is eligible for bridging:
* - the identifier is a well-formed Stellar asset,
* - the asset is supported (native XLM or in the supported list), and
* - the asset is not restricted (by code or issuer).
*
* Parsing/validation rules mirror the asset compatibility scanner but are
* inlined so this validator has no runtime dependency on the Stellar SDK.
*/

import {
AssetEligibilityConfig,
AssetEligibilityResult,
EligibilityIssue,
EligibilityStatus,
StellarAsset,
} from './types';

// Self-contained Stellar asset parsing/validation. These mirror the rules in
// the asset compatibility scanner but are inlined so the eligibility validator
// has no runtime dependency on the Stellar SDK (keeping it portable and
// independently testable).
function isNativeAsset(code: string): boolean {
return code === 'XLM' || code === 'native';
}

function isValidAssetCode(code: string): boolean {
const trimmed = code.trim();
return trimmed.length >= 1 && trimmed.length <= 12 && /^[a-zA-Z0-9]+$/.test(trimmed);
}

/** Stellar account/issuer keys are 56-char strkeys starting with `G` (base32). */
function isValidIssuer(issuer: string): boolean {
return /^G[A-Z2-7]{55}$/.test(issuer.trim());
}

function parseAsset(assetString: string): StellarAsset | null {
if (!assetString || !assetString.trim()) return null;
const value = assetString.trim();
if (isNativeAsset(value)) return { code: 'XLM' };

const parts = value.split(':');
if (parts.length === 2) {
const [code, issuer] = parts;
if (!isValidAssetCode(code) || !isValidIssuer(issuer.trim())) return null;
return { code: code.trim(), issuer: issuer.trim() };
}
if (isValidAssetCode(value)) return { code: value };
return null;
}

const DEFAULT_CONFIG: AssetEligibilityConfig = {
supportedAssetCodes: ['USDC', 'USDT', 'EURC'],
restrictedAssetCodes: [],
restrictedIssuers: [],
allowNative: true,
allowUnlistedAssets: false,
requireIssuerForNonNative: true,
};

export class StellarAssetEligibilityValidator {
private readonly config: AssetEligibilityConfig;

constructor(config: Partial<AssetEligibilityConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}

/** Validate a single asset identifier (e.g. "XLM", "USDC:GA5Z...""). */
validate(assetIdentifier: string): AssetEligibilityResult {
const checkedAt = Date.now();
const asset = parseAsset(assetIdentifier);

const build = (
eligible: boolean,
status: EligibilityStatus,
issues: EligibilityIssue[],
): AssetEligibilityResult => ({
eligible,
status,
assetIdentifier,
asset,
issues,
checkedAt,
});

// 1. Format: must parse and have a valid code.
if (!asset || !isValidAssetCode(asset.code)) {
return build(false, 'invalid', [
{
severity: 'error',
code: 'INVALID_ASSET_FORMAT',
message: `Failed to parse asset "${assetIdentifier}". Expected "CODE" or "CODE:ISSUER".`,
},
]);
}

const native = isNativeAsset(asset.code);

// Issuer well-formedness for non-native assets.
if (!native) {
if (asset.issuer && !isValidIssuer(asset.issuer)) {
return build(false, 'invalid', [
{
severity: 'error',
code: 'INVALID_ISSUER',
message: `Asset "${assetIdentifier}" has an invalid issuer address.`,
},
]);
}
if (!asset.issuer && this.config.requireIssuerForNonNative) {
return build(false, 'invalid', [
{
severity: 'error',
code: 'MISSING_ISSUER',
message: `Non-native asset "${asset.code}" must declare an issuer address.`,
},
]);
}
}

// 2. Restrictions take precedence over support: an explicitly restricted
// asset or issuer is never eligible.
const restrictionIssues = this.collectRestrictionIssues(asset, native);
if (restrictionIssues.length > 0) {
return build(false, 'restricted', restrictionIssues);
}

// 3. Support.
if (native) {
if (!this.config.allowNative) {
return build(false, 'unsupported', [
{
severity: 'error',
code: 'NATIVE_NOT_ELIGIBLE',
message: 'The native asset (XLM) is not eligible for bridging in this configuration.',
},
]);
}
return build(true, 'eligible', []);
}

const supported =
this.config.supportedAssetCodes.includes(asset.code) || this.config.allowUnlistedAssets;
if (!supported) {
return build(false, 'unsupported', [
{
severity: 'error',
code: 'ASSET_UNSUPPORTED',
message: `Asset "${asset.code}" is not in the list of supported bridge assets.`,
},
]);
}

return build(true, 'eligible', []);
}

/** Convenience boolean check. */
isEligible(assetIdentifier: string): boolean {
return this.validate(assetIdentifier).eligible;
}

/** Validate many identifiers at once. */
validateMany(assetIdentifiers: string[]): AssetEligibilityResult[] {
return assetIdentifiers.map((id) => this.validate(id));
}

/**
* Filter a list of asset identifiers down to the eligible ones — intended as
* a pre-filter so unsupported/restricted assets never enter route search.
*/
filterEligible(assetIdentifiers: string[]): string[] {
return assetIdentifiers.filter((id) => this.isEligible(id));
}

private collectRestrictionIssues(asset: StellarAsset, native: boolean): EligibilityIssue[] {
const issues: EligibilityIssue[] = [];
if (this.config.restrictedAssetCodes.includes(asset.code)) {
issues.push({
severity: 'error',
code: 'ASSET_RESTRICTED',
message: `Asset "${asset.code}" is restricted from bridging.`,
});
}
if (!native && asset.issuer && this.config.restrictedIssuers.includes(asset.issuer)) {
issues.push({
severity: 'error',
code: 'ISSUER_RESTRICTED',
message: `Issuer "${asset.issuer}" is restricted from bridging.`,
});
}
return issues;
}
}
Loading