From 64864073b4ef462b6a9746b729aa59c994f233d8 Mon Sep 17 00:00:00 2001 From: David Emulo <161654052+Davidemulo@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:36:22 +0100 Subject: [PATCH] fix: sync vendor registry contract client --- context/progress-tracker.md | 10 ++ .../clients/vendor-registry.client.ts | 71 +++++++++++--- src/stellar/contracts/index.ts | 1 + src/stellar/contracts/interfaces/index.ts | 1 + .../interfaces/vendor-registry.interface.ts | 4 +- .../contracts/mocks/vendor-registry.mock.ts | 2 +- .../clients/vendor-registry.client.spec.ts | 92 +++++++++++++++++++ 7 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 3a5dd01..3e3a6fe 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,16 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-07-22 + +- Synchronized the vendor-registry client with the deployed contract by using + the current function names and Stellar `Address` arguments. +- Replaced the stale vendor `active` flag with the contract's typed status, + added strict status decoding, and limited missing-vendor handling to contract + error code 4 so integration drift now fails loudly. +- Added client-level tests for exact function names, argument encoding, status + validation, and contract error handling (#96). + ## 2026-07-19 - Wired `LiquidityContractClient` (restored under `src/blockchain/contracts/liquidity-contract.client.ts`) into `LiquidityService` constructor. diff --git a/src/stellar/contracts/clients/vendor-registry.client.ts b/src/stellar/contracts/clients/vendor-registry.client.ts index 1954d20..eb53ee2 100644 --- a/src/stellar/contracts/clients/vendor-registry.client.ts +++ b/src/stellar/contracts/clients/vendor-registry.client.ts @@ -2,9 +2,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; import { SorobanService } from '../../../blockchain/soroban/soroban.service'; -import { VendorInfo, VENDOR_REGISTRY_CONTRACT_ID_KEY } from '../interfaces/vendor-registry.interface'; +import { + VendorInfo, + VendorStatus, + VENDOR_REGISTRY_CONTRACT_ID_KEY, +} from '../interfaces/vendor-registry.interface'; import { ContractNotConfiguredError, ContractReadError } from '../errors'; +const VENDOR_STATUSES: readonly VendorStatus[] = [ + 'Pending', + 'Approved', + 'Suspended', + 'Rejected', +]; + @Injectable() export class VendorRegistryContractClient { private readonly logger = new Logger(VendorRegistryContractClient.name); @@ -28,17 +39,19 @@ export class VendorRegistryContractClient { throw new ContractNotConfiguredError('Vendor registry contract'); } - const vendorIdArg = StellarSdk.nativeToScVal(vendorId, { type: 'string' }); + const vendorAddressArg = this.toAddressScVal(vendorId); try { const result = await this.sorobanService.simulateContractCall( this.contractId, - 'is_vendor_active', - [vendorIdArg], + 'is_active', + [vendorAddressArg], ); return Boolean(StellarSdk.scValToNative(result)); } catch (error) { - this.logger.error(`Failed to check vendor active status for ${vendorId}: ${error.message}`); + this.logger.error( + `Failed to check vendor active status for ${vendorId}: ${this.getErrorMessage(error)}`, + ); throw new ContractReadError('vendor active status'); } } @@ -48,13 +61,13 @@ export class VendorRegistryContractClient { throw new ContractNotConfiguredError('Vendor registry contract'); } - const vendorIdArg = StellarSdk.nativeToScVal(vendorId, { type: 'string' }); + const vendorAddressArg = this.toAddressScVal(vendorId); try { const result = await this.sorobanService.simulateContractCall( this.contractId, - 'get_vendor', - [vendorIdArg], + 'get_vendor_info', + [vendorAddressArg], ); const raw = StellarSdk.scValToNative(result) as Record; @@ -63,20 +76,48 @@ export class VendorRegistryContractClient { } return { - id: String(raw['id'] ?? raw['vendor_id'] ?? ''), + id: String(raw['id'] ?? raw['vendor_id'] ?? vendorId), name: String(raw['name'] ?? ''), - active: Boolean(raw['active'] ?? raw['is_active'] ?? false), + status: this.parseVendorStatus(raw['status']), }; } catch (error) { - if ( - error.message?.includes('HostError') || - error.message?.includes('Status(ContractError') - ) { + if (this.isVendorNotFoundError(error)) { this.logger.debug(`No vendor found for ${vendorId}`); return null; } - this.logger.error(`Failed to get vendor ${vendorId}: ${error.message}`); + this.logger.error(`Failed to get vendor ${vendorId}: ${this.getErrorMessage(error)}`); throw new ContractReadError('vendor info'); } } + + private toAddressScVal(vendorId: string): StellarSdk.xdr.ScVal { + return StellarSdk.nativeToScVal(StellarSdk.Address.fromString(vendorId), { + type: 'address', + }); + } + + private parseVendorStatus(rawStatus: unknown): VendorStatus { + const status = Array.isArray(rawStatus) ? rawStatus[0] : rawStatus; + + if (typeof status === 'string' && VENDOR_STATUSES.includes(status as VendorStatus)) { + return status as VendorStatus; + } + + throw new Error(`Unknown vendor status: ${String(status)}`); + } + + private isVendorNotFoundError(error: unknown): boolean { + const message = this.getErrorMessage(error); + + return ( + message.includes('VendorNotFound') || + /Error\(Contract,\s*#?4\)|ContractError\(4\)|Status\(ContractError\(4\)\)/.test( + message, + ) + ); + } + + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } } diff --git a/src/stellar/contracts/index.ts b/src/stellar/contracts/index.ts index 852e470..84a339d 100644 --- a/src/stellar/contracts/index.ts +++ b/src/stellar/contracts/index.ts @@ -14,6 +14,7 @@ export type { CreateLoanParams, PoolStats, VendorInfo, + VendorStatus, ProtocolParameters, } from './interfaces'; diff --git a/src/stellar/contracts/interfaces/index.ts b/src/stellar/contracts/interfaces/index.ts index 794e512..73fbb87 100644 --- a/src/stellar/contracts/interfaces/index.ts +++ b/src/stellar/contracts/interfaces/index.ts @@ -15,6 +15,7 @@ export { LIQUIDITY_POOL_CONTRACT_ID_KEY } from './liquidity-pool.interface'; export type { VendorInfo, + VendorStatus, IVendorRegistryClient, } from './vendor-registry.interface'; export { VENDOR_REGISTRY_CONTRACT_ID_KEY } from './vendor-registry.interface'; diff --git a/src/stellar/contracts/interfaces/vendor-registry.interface.ts b/src/stellar/contracts/interfaces/vendor-registry.interface.ts index 761c2f5..aec9d32 100644 --- a/src/stellar/contracts/interfaces/vendor-registry.interface.ts +++ b/src/stellar/contracts/interfaces/vendor-registry.interface.ts @@ -1,9 +1,11 @@ export const VENDOR_REGISTRY_CONTRACT_ID_KEY = 'VENDOR_REGISTRY_CONTRACT_ID'; +export type VendorStatus = 'Pending' | 'Approved' | 'Suspended' | 'Rejected'; + export interface VendorInfo { id: string; name: string; - active: boolean; + status: VendorStatus; } export interface IVendorRegistryClient { diff --git a/src/stellar/contracts/mocks/vendor-registry.mock.ts b/src/stellar/contracts/mocks/vendor-registry.mock.ts index b6e74c4..feaec7d 100644 --- a/src/stellar/contracts/mocks/vendor-registry.mock.ts +++ b/src/stellar/contracts/mocks/vendor-registry.mock.ts @@ -11,7 +11,7 @@ export class MockVendorRegistryContractClient { async (vendorId: string): Promise => ({ id: vendorId, name: 'Mock Vendor', - active: true, + status: 'Approved', }), ); } diff --git a/test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts b/test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts new file mode 100644 index 0000000..103ad97 --- /dev/null +++ b/test/unit/stellar/contracts/clients/vendor-registry.client.spec.ts @@ -0,0 +1,92 @@ +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { SorobanService } from '../../../../../src/blockchain/soroban/soroban.service'; +import { ContractReadError } from '../../../../../src/stellar/contracts/errors'; +import { VendorRegistryContractClient } from '../../../../../src/stellar/contracts/clients/vendor-registry.client'; +import { VENDOR_REGISTRY_CONTRACT_ID_KEY } from '../../../../../src/stellar/contracts/interfaces/vendor-registry.interface'; + +describe('VendorRegistryContractClient', () => { + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + const vendorAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const simulateContractCall = jest.fn(); + const sorobanService = { simulateContractCall } as unknown as SorobanService; + const configService = { + get: jest.fn((key: string) => + key === VENDOR_REGISTRY_CONTRACT_ID_KEY ? contractId : undefined, + ), + } as unknown as ConfigService; + let client: VendorRegistryContractClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new VendorRegistryContractClient(sorobanService, configService); + }); + + it('calls is_active with a Stellar Address argument', async () => { + simulateContractCall.mockResolvedValue(StellarSdk.nativeToScVal(true)); + + await expect(client.isVendorActive(vendorAddress)).resolves.toBe(true); + + expect(simulateContractCall).toHaveBeenCalledWith( + contractId, + 'is_active', + [expect.anything()], + ); + expect(StellarSdk.nativeToScVal).toHaveBeenCalledWith(vendorAddress, { + type: 'address', + }); + }); + + it('calls get_vendor_info and maps an approved status', async () => { + simulateContractCall.mockResolvedValue( + StellarSdk.nativeToScVal({ + name: 'Course Vendor', + registration_date: 1n, + status: ['Approved'], + total_sales: 0n, + }), + ); + + await expect(client.getVendor(vendorAddress)).resolves.toEqual({ + id: vendorAddress, + name: 'Course Vendor', + status: 'Approved', + }); + expect(simulateContractCall).toHaveBeenCalledWith( + contractId, + 'get_vendor_info', + [expect.anything()], + ); + expect(StellarSdk.nativeToScVal).toHaveBeenCalledWith(vendorAddress, { + type: 'address', + }); + }); + + it('rejects an unknown contract status', async () => { + simulateContractCall.mockResolvedValue( + StellarSdk.nativeToScVal({ name: 'Vendor', status: ['Unknown'] }), + ); + + await expect(client.getVendor(vendorAddress)).rejects.toBeInstanceOf( + ContractReadError, + ); + }); + + it('returns null only for the vendor-not-found contract error', async () => { + simulateContractCall.mockRejectedValue( + new Error('HostError: Error(Contract, #4)'), + ); + + await expect(client.getVendor(vendorAddress)).resolves.toBeNull(); + }); + + it('rethrows other host errors as a contract read error', async () => { + simulateContractCall.mockRejectedValue( + new Error('HostError: Error(Contract, #2)'), + ); + + await expect(client.getVendor(vendorAddress)).rejects.toBeInstanceOf( + ContractReadError, + ); + }); +});