Skip to content

Commit 9a3fcbc

Browse files
Merge pull request #55 from kryputh/fix/issue-17-security-deposit-lock-gate
Fix/issue 17 security deposit lock gate
2 parents 74b6ba2 + 96fbab5 commit 9a3fcbc

File tree

3 files changed

+408
-3
lines changed

3 files changed

+408
-3
lines changed

index.js

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
1+
require('dotenv').config();
2+
13
const express = require('express');
24
const cors = require('cors');
5+
const {
6+
createSecurityDepositLockService,
7+
requireLockedSecurityDeposit,
8+
} = require('./services/securityDepositLock');
9+
10+
const port = process.env.PORT || 3000;
311
const AvailabilityService = require('./services/availabilityService');
412
const AssetMetadataService = require('./services/assetMetadataService');
513
const AutoReclaimWorker = require('./services/autoReclaimWorker');
614

715
const app = express();
816
const port = 3000;
917

10-
app.use(cors());
11-
app.use(express.json());
18+
function createApp({ securityDepositService } = {}) {
19+
const app = express();
20+
const depositGatekeeper =
21+
securityDepositService ?? createSecurityDepositLockService();
22+
23+
app.use(cors());
24+
app.use(express.json());
1225

26+
app.get('/', (req, res) => {
27+
res.json({
28+
project: 'LeaseFlow Protocol',
29+
status: 'Active',
30+
contract_id: 'CAEGD57WVTVQSYWYB23AISBW334QO7WNA5XQ56S45GH6BP3D2AVHKUG4',
31+
});
1332
// Initialize services
1433
const availabilityService = new AvailabilityService();
1534
const assetMetadataService = new AssetMetadataService();
@@ -417,7 +436,44 @@ app.get('/status', (req, res) => {
417436
schedule: 'Every 10 minutes',
418437
last_check: new Date().toISOString()
419438
});
420-
});
439+
440+
app.post(
441+
'/move-in/generate-digital-key',
442+
requireLockedSecurityDeposit({
443+
action: 'Generate Digital Key',
444+
service: depositGatekeeper,
445+
}),
446+
(req, res) => {
447+
res.status(200).json({
448+
action: 'Generate Digital Key',
449+
allowed: true,
450+
message:
451+
'Security deposit verified. Digital key generation is authorized.',
452+
verification: req.securityDepositVerification,
453+
});
454+
},
455+
);
456+
457+
app.post(
458+
'/move-in/release-address',
459+
requireLockedSecurityDeposit({
460+
action: 'Release Address',
461+
service: depositGatekeeper,
462+
}),
463+
(req, res) => {
464+
res.status(200).json({
465+
action: 'Release Address',
466+
allowed: true,
467+
message: 'Security deposit verified. Address release is authorized.',
468+
verification: req.securityDepositVerification,
469+
});
470+
},
471+
);
472+
473+
return app;
474+
}
475+
476+
const app = createApp();
421477

422478
if (require.main === module) {
423479
const autoReclaimWorker = new AutoReclaimWorker();
@@ -436,3 +492,5 @@ if (require.main === module) {
436492

437493
const availabilityService = new AvailabilityService();
438494
module.exports = app;
495+
module.exports.app = app;
496+
module.exports.createApp = createApp;

services/securityDepositLock.js

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
const STELLAR_DECIMALS = 7;
2+
const DECIMAL_AMOUNT_PATTERN = /^\d+(?:\.\d{1,7})?$/;
3+
4+
class SecurityDepositError extends Error {
5+
constructor(statusCode, code, message, details = {}) {
6+
super(message);
7+
this.name = 'SecurityDepositError';
8+
this.statusCode = statusCode;
9+
this.code = code;
10+
this.details = details;
11+
}
12+
}
13+
14+
function invalidAmountError(fieldName) {
15+
return new SecurityDepositError(
16+
400,
17+
`INVALID_${fieldName.toUpperCase()}`,
18+
`${fieldName} must be a non-negative amount with up to ${STELLAR_DECIMALS} decimal places.`,
19+
);
20+
}
21+
22+
function parseAmountToUnits(value, fieldName) {
23+
if (value === undefined || value === null || String(value).trim() === '') {
24+
throw new SecurityDepositError(
25+
400,
26+
`MISSING_${fieldName.toUpperCase()}`,
27+
`${fieldName} is required.`,
28+
);
29+
}
30+
31+
const normalized = String(value).trim();
32+
if (!DECIMAL_AMOUNT_PATTERN.test(normalized)) {
33+
throw invalidAmountError(fieldName);
34+
}
35+
36+
const [wholePart, fractionalPart = ''] = normalized.split('.');
37+
return BigInt(`${wholePart}${fractionalPart.padEnd(STELLAR_DECIMALS, '0')}`);
38+
}
39+
40+
function formatUnits(units) {
41+
const negative = units < 0n;
42+
const absoluteUnits = negative ? -units : units;
43+
const raw = absoluteUnits
44+
.toString()
45+
.padStart(STELLAR_DECIMALS + 1, '0');
46+
const wholePart = raw.slice(0, -STELLAR_DECIMALS);
47+
const fractionalPart = raw
48+
.slice(-STELLAR_DECIMALS)
49+
.replace(/0+$/, '');
50+
51+
return `${negative ? '-' : ''}${wholePart}${fractionalPart ? `.${fractionalPart}` : ''}`;
52+
}
53+
54+
function evaluateSecurityDepositLock({ depositAmount, escrowBalance }) {
55+
const depositUnits = parseAmountToUnits(depositAmount, 'deposit_amount');
56+
const escrowUnits = parseAmountToUnits(escrowBalance, 'escrow_balance');
57+
const missingUnits = depositUnits > escrowUnits ? depositUnits - escrowUnits : 0n;
58+
59+
return {
60+
allowed: missingUnits === 0n,
61+
deposit_amount: formatUnits(depositUnits),
62+
escrow_balance: formatUnits(escrowUnits),
63+
missing_amount: formatUnits(missingUnits),
64+
};
65+
}
66+
67+
async function fetchEscrowBalanceFromSoroban({
68+
leaseId,
69+
escrowContractId,
70+
action,
71+
balanceUrl = process.env.LEASEFLOW_ESCROW_BALANCE_URL,
72+
defaultContractId = process.env.LEASEFLOW_ESCROW_CONTRACT_ID,
73+
fetchImpl = global.fetch,
74+
} = {}) {
75+
if (!balanceUrl) {
76+
throw new SecurityDepositError(
77+
503,
78+
'ESCROW_BALANCE_PROVIDER_NOT_CONFIGURED',
79+
'Escrow balance provider is not configured.',
80+
);
81+
}
82+
83+
if (typeof fetchImpl !== 'function') {
84+
throw new SecurityDepositError(
85+
503,
86+
'ESCROW_BALANCE_PROVIDER_UNAVAILABLE',
87+
'Global fetch is not available to query the Soroban escrow balance provider.',
88+
);
89+
}
90+
91+
const url = new URL(balanceUrl);
92+
if (leaseId !== undefined && leaseId !== null && leaseId !== '') {
93+
url.searchParams.set('lease_id', String(leaseId));
94+
}
95+
96+
const resolvedContractId = escrowContractId || defaultContractId;
97+
if (resolvedContractId) {
98+
url.searchParams.set('escrow_contract_id', String(resolvedContractId));
99+
}
100+
101+
if (action) {
102+
url.searchParams.set('action', action);
103+
}
104+
105+
let response;
106+
try {
107+
response = await fetchImpl(url.toString(), {
108+
headers: { accept: 'application/json' },
109+
});
110+
} catch (error) {
111+
throw new SecurityDepositError(
112+
503,
113+
'ESCROW_BALANCE_PROVIDER_UNAVAILABLE',
114+
'Unable to reach the Soroban escrow balance provider.',
115+
{ reason: error instanceof Error ? error.message : 'Unknown error' },
116+
);
117+
}
118+
119+
if (!response.ok) {
120+
throw new SecurityDepositError(
121+
503,
122+
'ESCROW_BALANCE_PROVIDER_UNAVAILABLE',
123+
'The Soroban escrow balance provider returned an error.',
124+
{ status: response.status },
125+
);
126+
}
127+
128+
let payload;
129+
try {
130+
payload = await response.json();
131+
} catch (_error) {
132+
throw new SecurityDepositError(
133+
503,
134+
'ESCROW_BALANCE_PROVIDER_INVALID_RESPONSE',
135+
'The Soroban escrow balance provider returned invalid JSON.',
136+
);
137+
}
138+
139+
const balance =
140+
payload.escrow_balance ??
141+
payload.balance ??
142+
payload.amount ??
143+
payload.available_balance;
144+
145+
if (balance === undefined || balance === null || String(balance).trim() === '') {
146+
throw new SecurityDepositError(
147+
503,
148+
'ESCROW_BALANCE_PROVIDER_INVALID_RESPONSE',
149+
'The Soroban escrow balance provider did not return an escrow balance.',
150+
);
151+
}
152+
153+
return String(balance).trim();
154+
}
155+
156+
function createSecurityDepositLockService({
157+
getEscrowBalance = fetchEscrowBalanceFromSoroban,
158+
} = {}) {
159+
return {
160+
async verify({ action, leaseId, depositAmount, escrowContractId } = {}) {
161+
parseAmountToUnits(depositAmount, 'deposit_amount');
162+
163+
const escrowBalance = await getEscrowBalance({
164+
action,
165+
leaseId,
166+
escrowContractId,
167+
});
168+
169+
const evaluation = evaluateSecurityDepositLock({
170+
depositAmount,
171+
escrowBalance,
172+
});
173+
174+
if (!evaluation.allowed) {
175+
throw new SecurityDepositError(
176+
412,
177+
'SECURITY_DEPOSIT_NOT_LOCKED',
178+
`${action} is blocked until the full security deposit is locked in Soroban escrow.`,
179+
{
180+
lease_id: leaseId ?? null,
181+
escrow_contract_id: escrowContractId ?? null,
182+
...evaluation,
183+
},
184+
);
185+
}
186+
187+
return {
188+
action,
189+
lease_id: leaseId ?? null,
190+
escrow_contract_id: escrowContractId ?? null,
191+
...evaluation,
192+
};
193+
},
194+
};
195+
}
196+
197+
function requireLockedSecurityDeposit({ action, service }) {
198+
return async (req, res, next) => {
199+
try {
200+
const verification = await service.verify({
201+
action,
202+
leaseId: req.body?.lease_id,
203+
depositAmount: req.body?.deposit_amount,
204+
escrowContractId: req.body?.escrow_contract_id,
205+
});
206+
207+
req.securityDepositVerification = verification;
208+
next();
209+
} catch (error) {
210+
if (error instanceof SecurityDepositError) {
211+
return res.status(error.statusCode).json({
212+
error: error.code,
213+
message: error.message,
214+
details: error.details,
215+
});
216+
}
217+
218+
return res.status(500).json({
219+
error: 'SECURITY_DEPOSIT_VERIFICATION_FAILED',
220+
message: 'Unable to verify the security deposit lock.',
221+
details: { action },
222+
});
223+
}
224+
};
225+
}
226+
227+
module.exports = {
228+
SecurityDepositError,
229+
createSecurityDepositLockService,
230+
evaluateSecurityDepositLock,
231+
fetchEscrowBalanceFromSoroban,
232+
requireLockedSecurityDeposit,
233+
};

0 commit comments

Comments
 (0)