diff --git a/src/lib/soroban.contractErrors.test.ts b/src/lib/soroban.contractErrors.test.ts new file mode 100644 index 0000000..c8a15cb --- /dev/null +++ b/src/lib/soroban.contractErrors.test.ts @@ -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; + + 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(); + }); +}); diff --git a/src/lib/soroban.service.test.ts b/src/lib/soroban.service.test.ts index 0b2c700..8d26f27 100644 --- a/src/lib/soroban.service.test.ts +++ b/src/lib/soroban.service.test.ts @@ -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 () => { diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index 2bd4853..7ccbbfe 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -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 = { '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 { @@ -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 ─────────────────────────────────────────────── @@ -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), }; } @@ -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), }; } @@ -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) {