diff --git a/src/assets/eligibility/stellar/__tests__/validator.spec.ts b/src/assets/eligibility/stellar/__tests__/validator.spec.ts new file mode 100644 index 00000000..8828e6a8 --- /dev/null +++ b/src/assets/eligibility/stellar/__tests__/validator.spec.ts @@ -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); + }); + }); +}); diff --git a/src/assets/eligibility/stellar/index.ts b/src/assets/eligibility/stellar/index.ts new file mode 100644 index 00000000..3a79df2c --- /dev/null +++ b/src/assets/eligibility/stellar/index.ts @@ -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'; diff --git a/src/assets/eligibility/stellar/types.ts b/src/assets/eligibility/stellar/types.ts new file mode 100644 index 00000000..f5a7554f --- /dev/null +++ b/src/assets/eligibility/stellar/types.ts @@ -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; +} diff --git a/src/assets/eligibility/stellar/validator.ts b/src/assets/eligibility/stellar/validator.ts new file mode 100644 index 00000000..897afaae --- /dev/null +++ b/src/assets/eligibility/stellar/validator.ts @@ -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 = {}) { + 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; + } +}