Skip to content

Commit 61cdcc4

Browse files
authored
Merge pull request #1427 from Kwin-xeexee/stellar-error-mapper.ts
stellar-error-mapper.ts
2 parents 554dbbc + 29a5804 commit 61cdcc4

2 files changed

Lines changed: 216 additions & 1 deletion

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {
2+
EscrowContractError,
3+
ChainTimeoutError,
4+
SimulationError,
5+
StellarIntegrationError,
6+
SubmissionError,
7+
} from '../../stellar/errors/stellar-integration.errors';
8+
import { EscrowErrorCode } from '../../stellar/errors/escrow-error-code.enum';
9+
import {
10+
EscrowContractRejectedError,
11+
EscrowSimulationFailedError,
12+
EscrowSubmissionFailedError,
13+
PaymentFlowError,
14+
} from './payment-flow.errors';
15+
import { mapStellarFundingError } from './stellar-error-mapper';
16+
17+
/**
18+
* Every EscrowError variant the on-chain contract can return (mirrors
19+
* contracts/escrow/src/lib.rs exactly). If the contract adds a new
20+
* variant and someone forgets to update ESCROW_ERROR_MESSAGES, this
21+
* test will catch it because the Record type becomes incomplete.
22+
*/
23+
const ALL_ESCROW_ERROR_CODES: EscrowErrorCode[] = [
24+
EscrowErrorCode.NotInitialized,
25+
EscrowErrorCode.AlreadyInitialized,
26+
EscrowErrorCode.NotFound,
27+
EscrowErrorCode.AlreadyFunded,
28+
EscrowErrorCode.NotFunded,
29+
EscrowErrorCode.InvalidStatus,
30+
EscrowErrorCode.Unauthorized,
31+
EscrowErrorCode.InvalidAmount,
32+
EscrowErrorCode.InsufficientBalance,
33+
];
34+
35+
describe('mapStellarFundingError', () => {
36+
// ── EscrowContractError (9 variants) ──────────────────────────────────
37+
38+
describe('EscrowContractError → EscrowContractRejectedError', () => {
39+
it.each(ALL_ESCROW_ERROR_CODES)(
40+
'maps EscrowErrorCode.%s to an EscrowContractRejectedError',
41+
(code) => {
42+
const rawError = `HostError: Error(Contract, #${code})`;
43+
const input = new EscrowContractError(code, rawError);
44+
45+
const result = mapStellarFundingError(input);
46+
47+
expect(result).toBeInstanceOf(EscrowContractRejectedError);
48+
expect(result.code).toBe('ESCROW_CONTRACT_REJECTED');
49+
expect((result as EscrowContractRejectedError).escrowErrorCode).toBe(
50+
code,
51+
);
52+
},
53+
);
54+
55+
it.each(ALL_ESCROW_ERROR_CODES)(
56+
'provides a distinct, actionable message for EscrowErrorCode.%s',
57+
(code) => {
58+
const rawError = `HostError: Error(Contract, #${code})`;
59+
const input = new EscrowContractError(code, rawError);
60+
61+
const result = mapStellarFundingError(input);
62+
const message = (result as EscrowContractRejectedError).message;
63+
64+
// Every variant must produce a non-empty message that does NOT
65+
// look like the old generic catch-all (which just forwarded the
66+
// raw Soroban error string).
67+
expect(message.length).toBeGreaterThan(0);
68+
expect(message).not.toBe(rawError);
69+
// Actionable messages contain a verb / instruction for the caller.
70+
expect(message).not.toMatch(/^Escrow contract rejected the call/);
71+
},
72+
);
73+
74+
it.each(ALL_ESCROW_ERROR_CODES)(
75+
'maps EscrowErrorCode.%s to a 422 (UNPROCESSABLE_ENTITY)',
76+
(code) => {
77+
const input = new EscrowContractError(code, 'HostError');
78+
79+
const result = mapStellarFundingError(input);
80+
81+
expect(result.getStatus()).toBe(422);
82+
},
83+
);
84+
85+
it('each EscrowErrorCode produces a unique error message', () => {
86+
const messages = ALL_ESCROW_ERROR_CODES.map((code) => {
87+
const input = new EscrowContractError(code, `HostError: #${code}`);
88+
return (mapStellarFundingError(input) as EscrowContractRejectedError)
89+
.message;
90+
});
91+
92+
const unique = new Set(messages);
93+
expect(unique.size).toBe(ALL_ESCROW_ERROR_CODES.length);
94+
});
95+
});
96+
97+
// ── Non-escrow Stellar errors ─────────────────────────────────────────
98+
99+
describe('SimulationError → EscrowSimulationFailedError', () => {
100+
it('maps a simulation error to ESCROW_SIMULATION_FAILED', () => {
101+
const input = new SimulationError(
102+
'simulation failed',
103+
'HostError: insufficient balance',
104+
);
105+
106+
const result = mapStellarFundingError(input);
107+
108+
expect(result).toBeInstanceOf(EscrowSimulationFailedError);
109+
expect(result.code).toBe('ESCROW_SIMULATION_FAILED');
110+
});
111+
});
112+
113+
describe('SubmissionError → EscrowSubmissionFailedError', () => {
114+
it('maps a submission error to ESCROW_SUBMISSION_FAILED', () => {
115+
const input = new SubmissionError('tx rejected', { status: 'ERROR' });
116+
117+
const result = mapStellarFundingError(input);
118+
119+
expect(result).toBeInstanceOf(EscrowSubmissionFailedError);
120+
expect(result.code).toBe('ESCROW_SUBMISSION_FAILED');
121+
});
122+
});
123+
124+
describe('ChainTimeoutError → EscrowSubmissionFailedError', () => {
125+
it('maps a chain timeout to ESCROW_SUBMISSION_FAILED', () => {
126+
const input = new ChainTimeoutError('network never confirmed');
127+
128+
const result = mapStellarFundingError(input);
129+
130+
expect(result).toBeInstanceOf(EscrowSubmissionFailedError);
131+
expect(result.code).toBe('ESCROW_SUBMISSION_FAILED');
132+
});
133+
});
134+
135+
describe('generic StellarIntegrationError → EscrowSubmissionFailedError', () => {
136+
it('maps a non-specific Stellar integration error to ESCROW_SUBMISSION_FAILED', () => {
137+
class OtherStellarError extends StellarIntegrationError {
138+
constructor() {
139+
super('some other stellar error');
140+
}
141+
}
142+
const input = new OtherStellarError();
143+
144+
const result = mapStellarFundingError(input);
145+
146+
expect(result).toBeInstanceOf(EscrowSubmissionFailedError);
147+
expect(result.code).toBe('ESCROW_SUBMISSION_FAILED');
148+
});
149+
});
150+
151+
// ── Unknown / non-Stellar errors ──────────────────────────────────────
152+
153+
describe('unknown error → EscrowSubmissionFailedError', () => {
154+
it('maps a plain Error to ESCROW_SUBMISSION_FAILED', () => {
155+
const input = new Error('something broke');
156+
157+
const result = mapStellarFundingError(input);
158+
159+
expect(result).toBeInstanceOf(EscrowSubmissionFailedError);
160+
expect(result.code).toBe('ESCROW_SUBMISSION_FAILED');
161+
});
162+
163+
it('maps a non-Error value to ESCROW_SUBMISSION_FAILED', () => {
164+
const result = mapStellarFundingError('string error');
165+
166+
expect(result).toBeInstanceOf(EscrowSubmissionFailedError);
167+
expect(result.code).toBe('ESCROW_SUBMISSION_FAILED');
168+
});
169+
});
170+
171+
// ── Regression guard: never returns a bare generic 500 ────────────────
172+
173+
describe('none of the 9 escrow variants produce a generic fallback', () => {
174+
it.each(ALL_ESCROW_ERROR_CODES)(
175+
'EscrowErrorCode.%s is NOT mapped to EscrowSubmissionFailedError',
176+
(code) => {
177+
const input = new EscrowContractError(code, `HostError: #${code}`);
178+
const result = mapStellarFundingError(input);
179+
180+
expect(result).not.toBeInstanceOf(EscrowSubmissionFailedError);
181+
expect(result).not.toBeInstanceOf(EscrowSimulationFailedError);
182+
},
183+
);
184+
});
185+
});

backend/src/payments/errors/stellar-error-mapper.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,51 @@ import {
55
StellarIntegrationError,
66
SubmissionError,
77
} from '../../stellar/errors/stellar-integration.errors';
8+
import { EscrowErrorCode } from '../../stellar/errors/escrow-error-code.enum';
89
import {
910
EscrowContractRejectedError,
1011
EscrowSimulationFailedError,
1112
EscrowSubmissionFailedError,
1213
PaymentFlowError,
1314
} from './payment-flow.errors';
1415

16+
/**
17+
* Human-readable, actionable messages for every EscrowError variant.
18+
* Each message tells the caller *what happened* and *what to do next*,
19+
* so the frontend can surface a helpful notification instead of a
20+
* generic "something went wrong" (issue #1276).
21+
*/
22+
const ESCROW_ERROR_MESSAGES: Record<EscrowErrorCode, string> = {
23+
[EscrowErrorCode.NotInitialized]:
24+
'The escrow contract has not been initialised — contact support to set up the contract',
25+
[EscrowErrorCode.AlreadyInitialized]:
26+
'The escrow contract is already initialised — no further setup is needed',
27+
[EscrowErrorCode.NotFound]:
28+
'No escrow record exists for this shipment — verify the shipment ID and try again',
29+
[EscrowErrorCode.AlreadyFunded]:
30+
'This shipment has already been funded on-chain — no further action is needed',
31+
[EscrowErrorCode.NotFunded]:
32+
'The escrow for this shipment has not been funded yet — fund the escrow before attempting this action',
33+
[EscrowErrorCode.InvalidStatus]:
34+
'The escrow is not in a state that allows this operation — check the current escrow status',
35+
[EscrowErrorCode.Unauthorized]:
36+
'You are not authorised to perform this action on the escrow',
37+
[EscrowErrorCode.InvalidAmount]:
38+
'The funding amount must be a positive number',
39+
[EscrowErrorCode.InsufficientBalance]:
40+
'Insufficient token balance in the shipper wallet — top up the balance and try again',
41+
};
42+
1543
/**
1644
* Maps a StellarContractService failure to a structured, distinguishable
1745
* PaymentFlowError instead of letting a generic 500 reach the shipper
1846
* (issue #1276 acceptance criteria).
1947
*/
2048
export function mapStellarFundingError(error: unknown): PaymentFlowError {
2149
if (error instanceof EscrowContractError) {
22-
return new EscrowContractRejectedError(error.code, error.message);
50+
const actionableMessage =
51+
ESCROW_ERROR_MESSAGES[error.code] ?? error.message;
52+
return new EscrowContractRejectedError(error.code, actionableMessage);
2353
}
2454
if (error instanceof SimulationError) {
2555
return new EscrowSimulationFailedError({ rawError: error.rawError });

0 commit comments

Comments
 (0)