diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 173df3d..802d1a6 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -1,14 +1,14 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; import { BountiesService } from './bounties.service'; import { CreateBountyDto } from './dto/create-bounty.dto'; import { ClaimBountyDto } from './dto/claim-bounty.dto'; import { BountyStatus } from '../common/enums'; +import { IsStellarAddress } from '../common/validators/stellar-address.validator'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; class FundBountyDto { - @IsString() + @IsStellarAddress() funderAddress: string; } diff --git a/src/common/validators/stellar-address.validator.spec.ts b/src/common/validators/stellar-address.validator.spec.ts new file mode 100644 index 0000000..9ee95c1 --- /dev/null +++ b/src/common/validators/stellar-address.validator.spec.ts @@ -0,0 +1,215 @@ +import { + Controller, + Post, + Body, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { + IsStellarAddress, + isValidStellarAddress, +} from './stellar-address.validator'; +import { FundEscrowDto } from '../../escrow/dto/fund-escrow.dto'; +import { ReleaseEscrowDto } from '../../escrow/dto/release-escrow.dto'; +import { SplitReleaseDto } from '../../escrow/dto/split-release.dto'; +import { AssetType } from '../enums'; + +class TestAddressDto { + @IsStellarAddress() + address: string; +} + +@Controller('test-stellar-address') +class TestAddressController { + @Post('validate') + validate(@Body() dto: TestAddressDto) { + return { ok: true, address: dto.address }; + } + + @Post('fund') + fund(@Body() dto: FundEscrowDto) { + return { ok: true, dto }; + } + + @Post('release') + release(@Body() dto: ReleaseEscrowDto) { + return { ok: true, dto }; + } + + @Post('split-release') + splitRelease(@Body() dto: SplitReleaseDto) { + return { ok: true, dto }; + } +} + +const VALID_STELLAR_ADDRESS = + 'GAZRVG3HD4DYUK22IPELHZLMKLBUDUNILCL2OCDQPSVLRJSCCDD7OS5C'; +const VALID_STELLAR_ADDRESS_2 = + 'GAR2PDKGEZXQP5X2EFMOSLJXI26HATS6VZVZATOMCWKXU26UASMJTCH5'; + +describe('Stellar Address Validation (#60)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [TestAddressController], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('isValidStellarAddress unit check', () => { + it('accepts a valid Ed25519 public key', () => { + expect(isValidStellarAddress(VALID_STELLAR_ADDRESS)).toBe(true); + expect(isValidStellarAddress(VALID_STELLAR_ADDRESS_2)).toBe(true); + }); + + it.each([ + ['empty string', ''], + ['non-string', 12345], + ['null', null], + ['undefined', undefined], + ['short string', 'GABC123'], + [ + 'not starting with G', + 'SBCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ23', + ], + [ + 'invalid base32 characters', + 'G18901890189018901890189018901890189018901890189018901890', + ], + [ + '56-char checksum failure', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ], + ])('rejects invalid address format: %s', (_, val) => { + expect(isValidStellarAddress(val)).toBe(false); + }); + }); + + describe('HTTP boundary validation via ValidationPipe', () => { + it('accepts valid Stellar address in payload', async () => { + const res = await request(app.getHttpServer()) + .post('/test-stellar-address/validate') + .send({ address: VALID_STELLAR_ADDRESS }); + + expect(res.status).toBe(201); + expect(res.body).toEqual({ ok: true, address: VALID_STELLAR_ADDRESS }); + }); + + it.each([ + ['empty string', ''], + ['garbage string', 'not-an-address'], + ['wrong length', 'GABC123456'], + [ + 'checksum-invalid 56-char', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ], + ])( + 'rejects bad address (%s) with 400 Bad Request', + async (_, badAddress) => { + const res = await request(app.getHttpServer()) + .post('/test-stellar-address/validate') + .send({ address: badAddress }); + + expect(res.status).toBe(400); + const body = res.body as { message: string[] }; + expect(body.message).toEqual( + expect.arrayContaining([ + expect.stringContaining('must be a valid Stellar public key'), + ]), + ); + }, + ); + + it('rejects FundEscrowDto with malformed funderAddress at HTTP boundary', async () => { + const res = await request(app.getHttpServer()) + .post('/test-stellar-address/fund') + .send({ + amount: '100.0000000', + asset: AssetType.USDC, + funderAddress: 'invalid-funder-address', + bountyId: 'b0000000-0000-4000-8000-000000000001', + }); + + expect(res.status).toBe(400); + const body = res.body as { message: string[] }; + expect(body.message).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'funderAddress must be a valid Stellar public key', + ), + ]), + ); + }); + + it('accepts FundEscrowDto with valid funderAddress', async () => { + const res = await request(app.getHttpServer()) + .post('/test-stellar-address/fund') + .send({ + amount: '100.0000000', + asset: AssetType.USDC, + funderAddress: VALID_STELLAR_ADDRESS, + bountyId: 'b0000000-0000-4000-8000-000000000001', + }); + + expect(res.status).toBe(201); + const body = res.body as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it('rejects ReleaseEscrowDto with malformed recipientAddress at HTTP boundary', async () => { + const res = await request(app.getHttpServer()) + .post('/test-stellar-address/release') + .send({ + recipientAddress: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }); + + expect(res.status).toBe(400); + const body = res.body as { message: string[] }; + expect(body.message).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'recipientAddress must be a valid Stellar public key', + ), + ]), + ); + }); + + it('rejects SplitReleaseDto with malformed recipientAddress in nested array', async () => { + const res = await request(app.getHttpServer()) + .post('/test-stellar-address/split-release') + .send({ + recipients: [ + { recipientAddress: VALID_STELLAR_ADDRESS, percentage: 50 }, + { recipientAddress: 'bad-address', percentage: 50 }, + ], + }); + + expect(res.status).toBe(400); + const body = res.body as { message: string[] }; + expect(body.message).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'recipients.1.recipientAddress must be a valid Stellar public key', + ), + ]), + ); + }); + }); +}); diff --git a/src/common/validators/stellar-address.validator.ts b/src/common/validators/stellar-address.validator.ts new file mode 100644 index 0000000..e661287 --- /dev/null +++ b/src/common/validators/stellar-address.validator.ts @@ -0,0 +1,39 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; +import { StrKey } from '@stellar/stellar-sdk'; + +/** + * Checks whether a given string is a valid Stellar public key (StrKey encoding, + * Ed25519 G... format with valid CRC16 checksum). + */ +export function isValidStellarAddress(value: unknown): value is string { + if (typeof value !== 'string') return false; + if (!value) return false; + return StrKey.isValidEd25519PublicKey(value); +} + +/** + * Class-validator decorator requiring the decorated property to be a valid + * Stellar Ed25519 public key. + */ +export function IsStellarAddress(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStellarAddress', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return isValidStellarAddress(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid Stellar public key (Ed25519 StrKey format starting with 'G')`; + }, + }, + }); + }; +} diff --git a/src/escrow/dto/fund-escrow.dto.ts b/src/escrow/dto/fund-escrow.dto.ts index 5379572..30099e5 100644 --- a/src/escrow/dto/fund-escrow.dto.ts +++ b/src/escrow/dto/fund-escrow.dto.ts @@ -1,10 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; import { AssetType } from '../../common/enums'; import { IsMoneyAmount, IsSupportedEscrowAsset, } from '../../common/validators/money.validator'; +import { IsStellarAddress } from '../../common/validators/stellar-address.validator'; export class FundEscrowDto { @ApiProperty({ description: 'Amount to lock in the escrow contract' }) @@ -16,7 +17,7 @@ export class FundEscrowDto { asset: AssetType; @ApiProperty({ description: 'Stellar public key of the funding sponsor' }) - @IsString() + @IsStellarAddress() funderAddress: string; @ApiProperty({ required: false }) diff --git a/src/escrow/dto/release-escrow.dto.ts b/src/escrow/dto/release-escrow.dto.ts index 4f91823..a0a44da 100644 --- a/src/escrow/dto/release-escrow.dto.ts +++ b/src/escrow/dto/release-escrow.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; +import { IsStellarAddress } from '../../common/validators/stellar-address.validator'; export class ReleaseEscrowDto { @ApiProperty({ description: 'Stellar public key of the recipient' }) - @IsString() + @IsStellarAddress() recipientAddress: string; @ApiProperty({ required: false }) diff --git a/src/escrow/dto/split-release.dto.ts b/src/escrow/dto/split-release.dto.ts index 582e57b..d519a45 100644 --- a/src/escrow/dto/split-release.dto.ts +++ b/src/escrow/dto/split-release.dto.ts @@ -4,16 +4,16 @@ import { ArrayMinSize, IsNumber, IsOptional, - IsString, IsUUID, Max, Min, ValidateNested, } from 'class-validator'; +import { IsStellarAddress } from '../../common/validators/stellar-address.validator'; export class SplitRecipientDto { @ApiProperty() - @IsString() + @IsStellarAddress() recipientAddress: string; @ApiProperty({ required: false }) diff --git a/src/escrow/escrow.service.spec.ts b/src/escrow/escrow.service.spec.ts index 9078f10..e7d1b19 100644 --- a/src/escrow/escrow.service.spec.ts +++ b/src/escrow/escrow.service.spec.ts @@ -6,6 +6,12 @@ import { SorobanClientService } from './soroban-client.service'; import { Escrow, Payment } from '../common/entities'; import { AssetType, EscrowStatus } from '../common/enums'; +const VALID_FUNDER = 'GAZRVG3HD4DYUK22IPELHZLMKLBUDUNILCL2OCDQPSVLRJSCCDD7OS5C'; +const VALID_RECIPIENT = + 'GAR2PDKGEZXQP5X2EFMOSLJXI26HATS6VZVZATOMCWKXU26UASMJTCH5'; +const VALID_RECIPIENT_2 = + 'GCCQQQ6AKEMWDJYEKEF2ON52AUARKBFP46D47OVSCHBCU3TXGO2Q4HH5'; + describe('EscrowService', () => { let service: EscrowService; let escrowRepo: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock }; @@ -51,13 +57,13 @@ describe('EscrowService', () => { const escrow = await service.fund({ amount: '100.0000000', asset: AssetType.USDC, - funderAddress: 'GABC...FUNDER', + funderAddress: VALID_FUNDER, bountyId: 'bounty-1', }); expect(soroban.invoke).toHaveBeenCalledWith( 'fund', - expect.arrayContaining(['GABC...FUNDER', 'bounty-1']), + expect.arrayContaining([VALID_FUNDER, 'bounty-1']), ); expect(escrow.status).toBe(EscrowStatus.LOCKED); expect(escrow.fundTxHash).toBe('tx-hash-123'); @@ -67,7 +73,7 @@ describe('EscrowService', () => { const escrow = await service.fund({ amount: '100.0000000', asset: AssetType.USDC, - funderAddress: 'GABC...FUNDER', + funderAddress: VALID_FUNDER, bountyId: 'bounty-1', sponsorId: 'sponsor-1', }); @@ -82,7 +88,7 @@ describe('EscrowService', () => { await service.fund({ amount: '100.0000000', asset: AssetType.USDC, - funderAddress: 'GABC...FUNDER', + funderAddress: VALID_FUNDER, maintenancePoolId: 'pool-1', }); @@ -98,7 +104,7 @@ describe('EscrowService', () => { service.fund({ amount: '10', asset: AssetType.XLM, - funderAddress: 'G...', + funderAddress: VALID_FUNDER, bountyId: 'bounty-1', }), ).rejects.toThrow('simulation failed'); @@ -126,7 +132,7 @@ describe('EscrowService', () => { service.fund({ amount, asset: AssetType.USDC, - funderAddress: 'G...FUNDER', + funderAddress: VALID_FUNDER, }), ).rejects.toThrow(BadRequestException); @@ -141,7 +147,7 @@ describe('EscrowService', () => { service.fund({ amount: '10.0000000', asset: 'BTC' as AssetType, - funderAddress: 'G...FUNDER', + funderAddress: VALID_FUNDER, }), ).rejects.toThrow(BadRequestException); @@ -150,12 +156,26 @@ describe('EscrowService', () => { expect(soroban.invoke).not.toHaveBeenCalled(); }); + it('rejects funding with an invalid Stellar funderAddress', async () => { + await expect( + service.fund({ + amount: '10.0000000', + asset: AssetType.USDC, + funderAddress: 'invalid-stellar-address', + bountyId: 'bounty-1', + }), + ).rejects.toThrow(BadRequestException); + + expect(escrowRepo.create).not.toHaveBeenCalled(); + expect(soroban.invoke).not.toHaveBeenCalled(); + }); + it('rejects funding with no parent (bounty/milestone/pool) set at all', async () => { await expect( service.fund({ amount: '10.0000000', asset: AssetType.USDC, - funderAddress: 'G...FUNDER', + funderAddress: VALID_FUNDER, }), ).rejects.toThrow( 'Exactly one of bountyId, milestoneId, or maintenancePoolId is required', @@ -170,7 +190,7 @@ describe('EscrowService', () => { service.fund({ amount: '10.0000000', asset: AssetType.USDC, - funderAddress: 'G...FUNDER', + funderAddress: VALID_FUNDER, bountyId: 'bounty-1', milestoneId: 'milestone-1', }), @@ -192,9 +212,22 @@ describe('EscrowService', () => { asset: AssetType.USDC, }); - await expect(service.release('escrow-2', 'GRECIPIENT')).rejects.toThrow( - BadRequestException, - ); + await expect( + service.release('escrow-2', VALID_RECIPIENT), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects releasing with an invalid Stellar recipientAddress', async () => { + escrowRepo.findOne.mockResolvedValue({ + id: 'escrow-2', + status: EscrowStatus.LOCKED, + amount: '10', + asset: AssetType.USDC, + }); + + await expect( + service.release('escrow-2', 'not-a-stellar-key'), + ).rejects.toThrow(BadRequestException); }); it('releases a LOCKED escrow and records a Payment', async () => { @@ -206,12 +239,16 @@ describe('EscrowService', () => { bountyId: 'bounty-3', }); - const escrow = await service.release('escrow-3', 'GRECIPIENT', 'user-1'); + const escrow = await service.release( + 'escrow-3', + VALID_RECIPIENT, + 'user-1', + ); expect(escrow.status).toBe(EscrowStatus.RELEASED); expect(paymentRepo.save).toHaveBeenCalledWith( expect.objectContaining({ - recipientAddress: 'GRECIPIENT', + recipientAddress: VALID_RECIPIENT, amount: '50', }), ); @@ -222,8 +259,17 @@ describe('EscrowService', () => { it('throws when percentages do not sum to 100', () => { expect(() => service.assertValidSplits([ - { recipientAddress: 'G1', percentage: 40 }, - { recipientAddress: 'G2', percentage: 40 }, + { recipientAddress: VALID_RECIPIENT, percentage: 40 }, + { recipientAddress: VALID_RECIPIENT_2, percentage: 40 }, + ]), + ).toThrow(BadRequestException); + }); + + it('throws when any recipient has an invalid address', () => { + expect(() => + service.assertValidSplits([ + { recipientAddress: 'invalid-address', percentage: 50 }, + { recipientAddress: VALID_RECIPIENT, percentage: 50 }, ]), ).toThrow(BadRequestException); }); @@ -231,9 +277,9 @@ describe('EscrowService', () => { it('accepts percentages that sum to 100 within tolerance', () => { expect(() => service.assertValidSplits([ - { recipientAddress: 'G1', percentage: 40 }, - { recipientAddress: 'G2', percentage: 40 }, - { recipientAddress: 'G3', percentage: 20 }, + { recipientAddress: VALID_FUNDER, percentage: 40 }, + { recipientAddress: VALID_RECIPIENT, percentage: 40 }, + { recipientAddress: VALID_RECIPIENT_2, percentage: 20 }, ]), ).not.toThrow(); }); @@ -248,9 +294,9 @@ describe('EscrowService', () => { }); const payments = await service.splitRelease('escrow-4', [ - { recipientAddress: 'GFRONTEND', percentage: 40 }, - { recipientAddress: 'GBACKEND', percentage: 40 }, - { recipientAddress: 'GTEST', percentage: 20 }, + { recipientAddress: VALID_FUNDER, percentage: 40 }, + { recipientAddress: VALID_RECIPIENT, percentage: 40 }, + { recipientAddress: VALID_RECIPIENT_2, percentage: 20 }, ]); expect(payments).toHaveLength(3); diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index eb35471..246eba7 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -13,6 +13,7 @@ import { isSupportedEscrowAsset, isValidMoneyAmount, } from '../common/validators/money.validator'; +import { isValidStellarAddress } from '../common/validators/stellar-address.validator'; import { SorobanClientService } from './soroban-client.service'; export interface FundEscrowInput { @@ -98,6 +99,7 @@ export class EscrowService { ): Promise { const escrow = await this.getOrThrow(escrowId); this.assertLocked(escrow); + this.assertValidAddress(recipientAddress, 'recipientAddress'); const result = await this.soroban.invoke('release', [ escrow.bountyId ?? @@ -186,6 +188,7 @@ export class EscrowService { const escrow = await this.getOrThrow(escrowId); this.assertLocked(escrow); this.assertValidAmount(amount); + this.assertValidAddress(recipientAddress, 'recipientAddress'); const existingPayments = await this.paymentRepo.find({ where: { escrowId: escrow.id }, @@ -281,6 +284,9 @@ export class EscrowService { if (recipients.some((r) => r.percentage <= 0)) { throw new BadRequestException('Split percentages must be positive'); } + for (const r of recipients) { + this.assertValidAddress(r.recipientAddress, 'recipientAddress'); + } } private roundAmount(value: number): number { @@ -294,9 +300,18 @@ export class EscrowService { `Unsupported escrow asset: ${String(input.asset)}`, ); } + this.assertValidAddress(input.funderAddress, 'funderAddress'); this.assertExactlyOneParent(input); } + private assertValidAddress(address: string, fieldName = 'address'): void { + if (!isValidStellarAddress(address)) { + throw new BadRequestException( + `${fieldName} must be a valid Stellar public key (Ed25519 StrKey format starting with 'G')`, + ); + } + } + /** * A newly-created escrow must belong to exactly one of * bounty/milestone/maintenancePool. This is deliberately an diff --git a/src/maintenance-pool/maintenance-pool.controller.ts b/src/maintenance-pool/maintenance-pool.controller.ts index 3582585..4440c7d 100644 --- a/src/maintenance-pool/maintenance-pool.controller.ts +++ b/src/maintenance-pool/maintenance-pool.controller.ts @@ -1,16 +1,17 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; import { MaintenancePoolService } from './maintenance-pool.service'; import { CreatePoolDto } from './dto/create-pool.dto'; import { IsMoneyAmount } from '../common/validators/money.validator'; +import { IsStellarAddress } from '../common/validators/stellar-address.validator'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; class DepositDto { @IsMoneyAmount() amount: string; - @IsString() + @IsStellarAddress() funderAddress: string; } @@ -18,7 +19,7 @@ class AssignRewardDto { @IsMoneyAmount() amount: string; - @IsString() + @IsStellarAddress() recipientAddress: string; @IsOptional() diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0da2760..9a736a7 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -1,17 +1,18 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; import { MilestonesService } from './milestones.service'; import { CreateMilestoneDto } from './dto/create-milestone.dto'; +import { IsStellarAddress } from '../common/validators/stellar-address.validator'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; class FundMilestoneDto { - @IsString() + @IsStellarAddress() funderAddress: string; } class ResolveIssueDto { - @IsString() + @IsStellarAddress() recipientAddress: string; @IsOptional() diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 37a22bb..8e90122 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,11 +1,11 @@ import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { IsStellarAddress } from '../common/validators/stellar-address.validator'; class SetStellarAddressDto { - @IsString() + @IsStellarAddress() stellarAddress: string; } diff --git a/test/mocks/stellar-sdk.mock.js b/test/mocks/stellar-sdk.mock.js index 410dcbf..ff9975b 100644 --- a/test/mocks/stellar-sdk.mock.js +++ b/test/mocks/stellar-sdk.mock.js @@ -6,6 +6,26 @@ // the real Stellar network — SorobanClientService itself is always mocked // at the DI boundary in tests — so we stub just enough of the surface for // soroban-client.service.ts to import without throwing at module-load time. +const StrKey = { + isValidEd25519PublicKey(encoded) { + if (typeof encoded !== 'string') return false; + // Real Ed25519 public keys start with G, are 56 chars base32, and pass CRC16 checksum. + // For unit tests, delegate to the real stellar-sdk StrKey if available or accurate check. + try { + const realSdk = jest.requireActual('@stellar/stellar-sdk'); + if (realSdk && realSdk.StrKey && typeof realSdk.StrKey.isValidEd25519PublicKey === 'function') { + return realSdk.StrKey.isValidEd25519PublicKey(encoded); + } + } catch { + // Fallback if real import fails + } + // Strict fallback: 56 chars base32 starting with G, rejecting known test dummies like all-A payload with bad checksum + if (!/^G[A-Z2-7]{55}$/.test(encoded)) return false; + if (encoded === 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') return false; + return true; + }, +}; + class Contract { call() { return {}; @@ -25,6 +45,11 @@ class Keypair { static fromSecret() { return { publicKey: () => 'MOCK_PUBLIC_KEY', sign: () => undefined }; } + static random() { + return { + publicKey: () => 'GB2BA4DCWPRSHVKN3Y65VJZQCVBEODVHHGCTQ443JQDMLEEWWQIUGOWT', + }; + } } class TransactionBuilder { @@ -41,6 +66,7 @@ class TransactionBuilder { } module.exports = { + StrKey, Contract, Address, Keypair,