Skip to content

Commit 22c67c3

Browse files
authored
Merge pull request #250 from observerr411/feature/contract-error-standardization
feat(errors): standardize contract error taxonomy and client decoding strategy
2 parents b9399ed + f4d2fa8 commit 22c67c3

File tree

5 files changed

+630
-76
lines changed

5 files changed

+630
-76
lines changed

lib/contracts/bill-payments.ts

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Server, Networks, Account, TransactionBuilder, Operation, BASE_FEE, StrKey } from '@stellar/stellar-sdk'
2+
import { createValidationError, parseContractError, ContractErrorCode } from '@/lib/errors/contract-errors'
23

34
const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon-testnet.stellar.org'
45
const NETWORK_PASSPHRASE = process.env.NETWORK_PASSPHRASE || Networks.TESTNET
@@ -13,36 +14,80 @@ function validatePublicKey(pk: string) {
1314
}
1415

1516
async function loadAccount(accountId: string) {
16-
if (!validatePublicKey(accountId)) throw new Error('invalid-account')
17-
return await server.loadAccount(accountId)
17+
if (!validatePublicKey(accountId)) {
18+
throw createValidationError(
19+
ContractErrorCode.INVALID_ACCOUNT,
20+
'Invalid Stellar account address',
21+
{ contractId: 'bill-payments', metadata: { accountId } }
22+
)
23+
}
24+
try {
25+
return await server.loadAccount(accountId)
26+
} catch (error) {
27+
throw parseContractError(error, {
28+
contractId: 'bill-payments',
29+
method: 'loadAccount'
30+
})
31+
}
1832
}
1933

2034
export async function buildCreateBillTx(owner: string, name: string, amount: number, dueDate: string, recurring: boolean, frequencyDays?: number) {
21-
if (!validatePublicKey(owner)) throw new Error('invalid-owner')
22-
if (!(amount > 0)) throw new Error('invalid-amount')
23-
if (recurring && !(frequencyDays && frequencyDays > 0)) throw new Error('invalid-frequency')
35+
if (!validatePublicKey(owner)) {
36+
throw createValidationError(
37+
ContractErrorCode.INVALID_ADDRESS,
38+
'Invalid owner address',
39+
{ contractId: 'bill-payments', method: 'buildCreateBillTx', metadata: { owner } }
40+
)
41+
}
42+
if (!(amount > 0)) {
43+
throw createValidationError(
44+
ContractErrorCode.INVALID_AMOUNT,
45+
'Amount must be greater than zero',
46+
{ contractId: 'bill-payments', method: 'buildCreateBillTx', metadata: { amount } }
47+
)
48+
}
49+
if (recurring && !(frequencyDays && frequencyDays > 0)) {
50+
throw createValidationError(
51+
ContractErrorCode.INVALID_FREQUENCY,
52+
'Frequency days must be greater than zero for recurring bills',
53+
{ contractId: 'bill-payments', method: 'buildCreateBillTx', metadata: { frequencyDays } }
54+
)
55+
}
2456
// basic date validation
25-
if (Number.isNaN(Date.parse(dueDate))) throw new Error('invalid-dueDate')
26-
27-
const acctResp = await loadAccount(owner)
28-
const source = new Account(owner, acctResp.sequence)
29-
30-
const txBuilder = new TransactionBuilder(source, {
31-
fee: BASE_FEE,
32-
networkPassphrase: NETWORK_PASSPHRASE,
33-
})
34-
35-
// Use several ManageData ops to keep values under the 64-byte limit per entry
36-
txBuilder.addOperation(Operation.manageData({ name: 'bill:name', value: name.slice(0, 64) }))
37-
txBuilder.addOperation(Operation.manageData({ name: 'bill:amount', value: String(amount) }))
38-
txBuilder.addOperation(Operation.manageData({ name: 'bill:dueDate', value: new Date(dueDate).toISOString() }))
39-
txBuilder.addOperation(Operation.manageData({ name: 'bill:recurring', value: recurring ? '1' : '0' }))
40-
if (recurring && frequencyDays) {
41-
txBuilder.addOperation(Operation.manageData({ name: 'bill:frequencyDays', value: String(frequencyDays) }))
57+
if (Number.isNaN(Date.parse(dueDate))) {
58+
throw createValidationError(
59+
ContractErrorCode.INVALID_DUE_DATE,
60+
'Invalid due date format',
61+
{ contractId: 'bill-payments', method: 'buildCreateBillTx', metadata: { dueDate } }
62+
)
4263
}
4364

44-
const tx = txBuilder.setTimeout(300).build()
45-
return tx.toXDR()
65+
try {
66+
const acctResp = await loadAccount(owner)
67+
const source = new Account(owner, acctResp.sequence)
68+
69+
const txBuilder = new TransactionBuilder(source, {
70+
fee: BASE_FEE,
71+
networkPassphrase: NETWORK_PASSPHRASE,
72+
})
73+
74+
// Use several ManageData ops to keep values under the 64-byte limit per entry
75+
txBuilder.addOperation(Operation.manageData({ name: 'bill:name', value: name.slice(0, 64) }))
76+
txBuilder.addOperation(Operation.manageData({ name: 'bill:amount', value: String(amount) }))
77+
txBuilder.addOperation(Operation.manageData({ name: 'bill:dueDate', value: new Date(dueDate).toISOString() }))
78+
txBuilder.addOperation(Operation.manageData({ name: 'bill:recurring', value: recurring ? '1' : '0' }))
79+
if (recurring && frequencyDays) {
80+
txBuilder.addOperation(Operation.manageData({ name: 'bill:frequencyDays', value: String(frequencyDays) }))
81+
}
82+
83+
const tx = txBuilder.setTimeout(300).build()
84+
return tx.toXDR()
85+
} catch (error) {
86+
throw parseContractError(error, {
87+
contractId: 'bill-payments',
88+
method: 'buildCreateBillTx'
89+
})
90+
}
4691
}
4792

4893
export async function buildPayBillTx(caller: string, billId: string) {

lib/contracts/insurance.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
scValToNative,
66
xdr,
77
} from "@stellar/stellar-sdk";
8+
import { parseContractError, createNotFoundError, createValidationError, ContractErrorCode } from "@/lib/errors/contract-errors";
89

910
// ── Config ──────────────────────────────────────────────────────────────────
1011
const SOROBAN_RPC_URL =
@@ -64,20 +65,33 @@ import {
6465
.setTimeout(30)
6566
.build();
6667

67-
const result = (await server.simulateTransaction(tx)) as {
68-
result?: { retval: xdr.ScVal };
69-
error?: string;
70-
};
71-
72-
if (result.error) {
73-
throw new Error(`Soroban simulation error: ${result.error}`);
74-
}
68+
try {
69+
const result = (await server.simulateTransaction(tx)) as {
70+
result?: { retval: xdr.ScVal };
71+
error?: string;
72+
};
7573

76-
if (!result.result?.retval) {
77-
throw new Error(`No return value from contract method: ${method}`);
74+
if (result.error) {
75+
throw parseContractError(new Error(`Soroban simulation error: ${result.error}`), {
76+
contractId: 'insurance',
77+
method
78+
});
79+
}
80+
81+
if (!result.result?.retval) {
82+
throw parseContractError(new Error(`No return value from contract method: ${method}`), {
83+
contractId: 'insurance',
84+
method
85+
});
86+
}
87+
88+
return result.result.retval;
89+
} catch (error) {
90+
throw parseContractError(error, {
91+
contractId: 'insurance',
92+
method
93+
});
7894
}
79-
80-
return result.result.retval;
8195
}
8296

8397
// ── Mapper ───────────────────────────────────────────────────────────────────

lib/contracts/savings-goals.ts

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
import * as StellarSdk from '@stellar/stellar-sdk';
44
import { BuildTxResult } from '@/lib/types/savings-goals';
5+
import { parseContractError, createSystemError, ContractErrorCode } from '@/lib/errors/contract-errors';
56

67
// Get contract ID from environment
78
const getContractId = (): string => {
89
const contractId = process.env.NEXT_PUBLIC_SAVINGS_GOALS_CONTRACT_ID;
910
if (!contractId) {
10-
throw new Error('NEXT_PUBLIC_SAVINGS_GOALS_CONTRACT_ID is not configured');
11+
throw createSystemError('NEXT_PUBLIC_SAVINGS_GOALS_CONTRACT_ID is not configured', {
12+
contractId: 'savings-goals'
13+
}, false);
1114
}
1215
return contractId;
1316
};
@@ -41,45 +44,52 @@ export async function buildCreateGoalTx(
4144
targetAmount: number,
4245
targetDate: string
4346
): Promise<BuildTxResult> {
44-
const config = getNetworkConfig();
45-
const contractId = getContractId();
46-
47-
// Create contract instance
48-
const contract = new StellarSdk.Contract(contractId);
49-
50-
// Convert parameters to Stellar SDK types
51-
const ownerAddress = new StellarSdk.Address(owner);
52-
const nameScVal = StellarSdk.nativeToScVal(name, { type: 'string' });
53-
const amountScVal = StellarSdk.nativeToScVal(targetAmount, { type: 'i128' });
54-
55-
// Convert date to Unix timestamp
56-
const targetTimestamp = Math.floor(new Date(targetDate).getTime() / 1000);
57-
const timestampScVal = StellarSdk.nativeToScVal(targetTimestamp, { type: 'u64' });
58-
59-
// Build the operation
60-
const operation = contract.call(
61-
'create_goal',
62-
ownerAddress.toScVal(),
63-
nameScVal,
64-
amountScVal,
65-
timestampScVal
66-
);
67-
68-
// Create source account
69-
const sourceAccount = await loadAccount(owner, config.rpcUrl);
70-
71-
// Build transaction
72-
const transaction = new StellarSdk.TransactionBuilder(sourceAccount, {
73-
fee: StellarSdk.BASE_FEE,
74-
networkPassphrase: config.networkPassphrase,
75-
})
76-
.addOperation(operation)
77-
.setTimeout(300) // 5 minutes
78-
.build();
79-
80-
return {
81-
xdr: transaction.toXDR()
82-
};
47+
try {
48+
const config = getNetworkConfig();
49+
const contractId = getContractId();
50+
51+
// Create contract instance
52+
const contract = new StellarSdk.Contract(contractId);
53+
54+
// Convert parameters to Stellar SDK types
55+
const ownerAddress = new StellarSdk.Address(owner);
56+
const nameScVal = StellarSdk.nativeToScVal(name, { type: 'string' });
57+
const amountScVal = StellarSdk.nativeToScVal(targetAmount, { type: 'i128' });
58+
59+
// Convert date to Unix timestamp
60+
const targetTimestamp = Math.floor(new Date(targetDate).getTime() / 1000);
61+
const timestampScVal = StellarSdk.nativeToScVal(targetTimestamp, { type: 'u64' });
62+
63+
// Build the operation
64+
const operation = contract.call(
65+
'create_goal',
66+
ownerAddress.toScVal(),
67+
nameScVal,
68+
amountScVal,
69+
timestampScVal
70+
);
71+
72+
// Create source account
73+
const sourceAccount = await loadAccount(owner, config.rpcUrl);
74+
75+
// Build transaction
76+
const transaction = new StellarSdk.TransactionBuilder(sourceAccount, {
77+
fee: StellarSdk.BASE_FEE,
78+
networkPassphrase: config.networkPassphrase,
79+
})
80+
.addOperation(operation)
81+
.setTimeout(300) // 5 minutes
82+
.build();
83+
84+
return {
85+
xdr: transaction.toXDR()
86+
};
87+
} catch (error) {
88+
throw parseContractError(error, {
89+
contractId: 'savings-goals',
90+
method: 'create_goal'
91+
});
92+
}
8393
}
8494

8595
/**

0 commit comments

Comments
 (0)