Skip to content
71 changes: 71 additions & 0 deletions src/lib/soroban.contractErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getContractErrorMessage } from './soroban';

describe('getContractErrorMessage', () => {
// Enumerates every entry in CONTRACT_ERROR_MESSAGES (soroban.ts), sourced
// from the deployed farming-pool contract's PoolError enum for codes 2-9
// (#146). Code '1' predates this table — see the comment above
// CONTRACT_ERROR_MESSAGES in soroban.ts for why it's kept as-is.
it.each([
['1', 'Assets are still locked'],
['2', 'The pool has not been initialized yet'],
['3', 'Invalid credit rate configuration'],
['4', 'Invalid boost multiplier configuration'],
['5', 'This wallet is not on the whitelist for this pool'],
['6', 'Amount is below the minimum stake for this pool'],
['7', 'This action requires the pool to be paused first'],
['8', 'No active stake or locked position was found for this wallet'],
['9', 'This pool is currently paused'],
])('maps code %s to %j', (code, expected) => {
expect(getContractErrorMessage(code)).toBe(expected);
});

it('accepts a decimal-string error code embedded in a longer message', () => {
expect(getContractErrorMessage('Host function failed with contract code: 6')).toBe(
'Amount is below the minimum stake for this pool',
);
});

it('accepts a hex-encoded error code', () => {
expect(getContractErrorMessage('0x6')).toBe(
'Amount is below the minimum stake for this pool',
);
});

describe('unmapped codes', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});

afterEach(() => {
warnSpy.mockRestore();
});

it('returns undefined for an intentionally-unmapped code', () => {
expect(getContractErrorMessage('99')).toBeUndefined();
});

it('logs the unmapped code distinctly so gaps are discoverable', () => {
getContractErrorMessage('99');

expect(warnSpy).toHaveBeenCalledWith('[SmartDrop] Unmapped contract error code:', '99');
});

it('does not warn for a mapped code', () => {
getContractErrorMessage('1');

expect(warnSpy).not.toHaveBeenCalled();
});
});

it('returns undefined without warning when no error code was extracted', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

expect(getContractErrorMessage(undefined)).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();

warnSpy.mockRestore();
});
});
39 changes: 39 additions & 0 deletions src/lib/soroban.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,10 +846,49 @@ describe("SorobanService RPC writes", () => {
success: true,
transactionHash: "boost-hash",
hash: "boost-hash",
status: "SUCCESS",
gasUsed: "777",
});
expect(walletApi.signTransaction).toHaveBeenCalledTimes(1);
expect(rpcServer.sendTransaction).toHaveBeenCalledTimes(1);
expect(rpcServer.getTransaction).toHaveBeenCalledWith("boost-hash");
});

it("setBoost returns decoded contract error details when confirmation fails (#146)", async () => {
const { service, rpcServer } = makeService();
mockAssembleTransactionPassthrough();
rpcServer.simulateTransaction.mockResolvedValue({
result: { auth: [makeAuthEntry("set_boost")] },
minResourceFee: "777",
});
rpcServer.sendTransaction.mockResolvedValue({
status: "PENDING",
hash: "failed-boost-hash",
});
rpcServer.getTransaction.mockResolvedValue({
status: "FAILED",
errorResult: "Host function failed with contract code: 5",
});
const walletApi = {
signTransaction: vi.fn(async (xdrEnvelope: string) => xdrEnvelope),
};

const result = await service.setBoost(
POOL_ID,
USER_PUBLIC_KEY,
40,
walletApi,
);

expect(result).toMatchObject({
success: false,
transactionHash: "failed-boost-hash",
hash: "failed-boost-hash",
status: "FAILED",
errorCode: "5",
error: "This wallet is not on the whitelist for this pool",
});
expect(rpcServer.getTransaction).toHaveBeenCalledWith("failed-boost-hash");
});

it("setBoost rejects invalid allocation percentages before RPC calls", async () => {
Expand Down
62 changes: 58 additions & 4 deletions src/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,28 @@ type PollTransactionResult = {
errorCode?: string;
};

// Codes 2-9 are sourced directly from the deployed farming-pool contract's
// `PoolError` enum (SmartDropLabs/smartdrop-contracts,
// soroban/contracts/farming-pool/src/types.rs) — the authoritative,
// currently-deployed error set, rather than a guess (#146). Code '1'
// predates this table and is left as-is: the contract's own code 1 is
// `AlreadyInitialized`, which doesn't match "Assets are still locked" — that
// specific failure (`unlock_assets` before `unlock_ledger`) is actually a
// plain Rust `assert!` in the current contract, not a typed `PoolError`, so
// it wouldn't surface via this numeric-code path at all. Left unchanged
// rather than silently reinterpreted, since there's no way to confirm here
// whether it reflects an intentional mapping against an older contract
// build or a stale assumption; worth a follow-up with the contracts team.
const CONTRACT_ERROR_MESSAGES: Record<string, string> = {
'1': 'Assets are still locked',
'2': 'The pool has not been initialized yet',
'3': 'Invalid credit rate configuration',
'4': 'Invalid boost multiplier configuration',
'5': 'This wallet is not on the whitelist for this pool',
'6': 'Amount is below the minimum stake for this pool',
'7': 'This action requires the pool to be paused first',
'8': 'No active stake or locked position was found for this wallet',
'9': 'This pool is currently paused',
};

function sleep(ms: number): Promise<void> {
Expand Down Expand Up @@ -487,7 +507,22 @@ function extractContractErrorCode(tx: unknown, resultXdr?: string): string | und

export function getContractErrorMessage(errorCode?: string): string | undefined {
const normalized = normalizeContractErrorCode(errorCode);
return normalized ? CONTRACT_ERROR_MESSAGES[normalized] : undefined;
if (!normalized) return undefined;

const message = CONTRACT_ERROR_MESSAGES[normalized];
if (!message) {
console.warn('[SmartDrop] Unmapped contract error code:', normalized);
}
return message;
}

/** `getContractErrorMessage(errorCode) ?? this` — always includes the raw
* code (when one was extracted) so a user or support agent has something
* concrete to reference, even for a still-unmapped code (#146). */
function genericOnChainFailureMessage(hash: string, errorCode?: string): string {
return errorCode
? `Transaction ${hash} failed on-chain (code ${errorCode})`
: `Transaction ${hash} failed on-chain`;
}

// ── Transaction signing safety ───────────────────────────────────────────────
Expand Down Expand Up @@ -1011,7 +1046,7 @@ export class SorobanService {
confirmation.status === 'TIMEOUT'
? 'Transaction confirmation is taking longer than expected.'
: getContractErrorMessage(confirmation.errorCode) ??
`Transaction ${submissionResult.hash} failed on-chain`,
genericOnChainFailureMessage(submissionResult.hash, confirmation.errorCode),
};
}

Expand Down Expand Up @@ -1154,7 +1189,7 @@ export class SorobanService {
confirmation.status === 'TIMEOUT'
? 'Transaction confirmation is taking longer than expected.'
: getContractErrorMessage(confirmation.errorCode) ??
`Transaction ${submissionResult.hash} failed on-chain`,
genericOnChainFailureMessage(submissionResult.hash, confirmation.errorCode),
};
}

Expand Down Expand Up @@ -1251,13 +1286,32 @@ export class SorobanService {
};
}

const confirmation = await this.pollTransactionStatus(submissionResult.hash);
if (confirmation.status !== 'SUCCESS') {
return {
success: false,
transactionHash: submissionResult.hash,
hash: submissionResult.hash,
status: confirmation.status,
resultXdr: confirmation.resultXdr,
errorCode: confirmation.errorCode,
error:
confirmation.status === 'TIMEOUT'
? 'Transaction confirmation is taking longer than expected.'
: getContractErrorMessage(confirmation.errorCode) ??
genericOnChainFailureMessage(submissionResult.hash, confirmation.errorCode),
};
}

return {
success: true,
transactionHash: submissionResult.hash,
hash: submissionResult.hash,
status: confirmation.status,
resultXdr: confirmation.resultXdr,
gasUsed: simulation.minResourceFee || '0',
};

} catch (error) {
console.error('Error setting boost:', error);
if (error instanceof SecurityError) {
Expand Down
Loading