Skip to content
Draft
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
10 changes: 10 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 56 additions & 15 deletions src/stellar/contracts/clients/vendor-registry.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
}
}
Expand All @@ -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<string, unknown>;

Expand All @@ -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);
}
}
1 change: 1 addition & 0 deletions src/stellar/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
CreateLoanParams,
PoolStats,
VendorInfo,
VendorStatus,
ProtocolParameters,
} from './interfaces';

Expand Down
1 change: 1 addition & 0 deletions src/stellar/contracts/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/stellar/contracts/mocks/vendor-registry.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class MockVendorRegistryContractClient {
async (vendorId: string): Promise<VendorInfo | null> => ({
id: vendorId,
name: 'Mock Vendor',
active: true,
status: 'Approved',
}),
);
}
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading