Skip to content

Commit e901971

Browse files
Merge pull request #60 from bamiebot-maker/feature/security-listing-verification
feat(security): verify listing ownership on-chain
2 parents d7a0322 + 1271af5 commit e901971

File tree

2 files changed

+294
-0
lines changed

2 files changed

+294
-0
lines changed

index.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ function createApp(dependencies = {}) {
3333
app.use(express.json());
3434
const express = require('express');
3535
const cors = require('cors');
36+
const { randomUUID } = require('crypto');
37+
3638
const multer = require('multer');
3739
const path = require('path');
3840
const fs = require('fs');
3941
const sharp = require('sharp');
4042
const app = express();
4143
const port = 3000;
44+
const listings = [];
45+
const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon.stellar.org';
4246

4347
const uploadDir = path.join(__dirname, 'uploads');
4448
if (!fs.existsSync(uploadDir)) {
@@ -296,6 +300,43 @@ function createApp({ securityDepositService } = {}) {
296300
const availabilityService = new AvailabilityService();
297301
const assetMetadataService = new AssetMetadataService();
298302

303+
function hasPositiveTrustline(balances, assetCode, assetIssuer) {
304+
return balances.some((balance) => {
305+
const isMatchingAsset =
306+
balance.asset_code === assetCode &&
307+
balance.asset_issuer === assetIssuer &&
308+
String(balance.asset_type || '').startsWith('credit_');
309+
310+
if (!isMatchingAsset) {
311+
return false;
312+
}
313+
314+
return Number.parseFloat(balance.balance || '0') > 0;
315+
});
316+
}
317+
318+
async function verifyNftOwnership({ lister, assetCode, assetIssuer }) {
319+
const response = await fetch(
320+
`${HORIZON_URL.replace(/\/$/, '')}/accounts/${encodeURIComponent(lister)}`
321+
);
322+
323+
if (response.status === 404) {
324+
return { isOwner: false, reason: 'ACCOUNT_NOT_FOUND' };
325+
}
326+
327+
if (!response.ok) {
328+
throw new Error(`Horizon lookup failed with status ${response.status}`);
329+
}
330+
331+
const account = await response.json();
332+
const balances = Array.isArray(account.balances) ? account.balances : [];
333+
334+
return {
335+
isOwner: hasPositiveTrustline(balances, assetCode, assetIssuer),
336+
reason: 'TRUSTLINE_MISSING_OR_EMPTY',
337+
};
338+
}
339+
299340
app.get('/', (req, res) => {
300341
res.json({
301342
project: 'LeaseFlow Protocol',
@@ -491,6 +532,52 @@ app.get('/api/assets/metadata', async (req, res) => {
491532
}
492533
});
493534

535+
app.get('/listings', (req, res) => {
536+
res.json({ listings });
537+
});
538+
539+
app.post('/listings', async (req, res) => {
540+
const { lister, assetCode, assetIssuer, price, metadata } = req.body || {};
541+
542+
if (!lister || !assetCode || !assetIssuer || price === undefined || price === null) {
543+
return res.status(400).json({
544+
error: 'Missing required fields',
545+
required: ['lister', 'assetCode', 'assetIssuer', 'price'],
546+
});
547+
}
548+
549+
try {
550+
const ownershipCheck = await verifyNftOwnership({ lister, assetCode, assetIssuer });
551+
552+
if (!ownershipCheck.isOwner) {
553+
return res.status(403).json({
554+
error: 'Lister does not own the NFT on-chain',
555+
reason: ownershipCheck.reason,
556+
});
557+
}
558+
559+
// The repo has no persistent database yet, so this in-memory insert marks the
560+
// exact point where verified listings would be written once a DB layer exists.
561+
const listing = {
562+
id: randomUUID(),
563+
lister,
564+
assetCode,
565+
assetIssuer,
566+
price,
567+
metadata: metadata || null,
568+
createdAt: new Date().toISOString(),
569+
};
570+
571+
listings.push(listing);
572+
return res.status(201).json({ listing });
573+
} catch (error) {
574+
return res.status(502).json({
575+
error: 'Unable to verify ownership against Horizon',
576+
details: error.message,
577+
});
578+
}
579+
});
580+
494581
app.post('/api/asset/:id/metadata', async (req, res) => {
495582
try {
496583
const { id } = req.params;
@@ -858,6 +945,15 @@ if (require.main === module) {
858945
});
859946
}
860947

948+
module.exports = {
949+
app,
950+
listings,
951+
resetListings() {
952+
listings.length = 0;
953+
},
954+
verifyNftOwnership,
955+
hasPositiveTrustline,
956+
};
861957
const availabilityService = new AvailabilityService();
862958
module.exports = app;
863959
module.exports.app = app;

tests/index.test.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
11
const request = require('supertest');
2+
const {
3+
app,
4+
listings,
5+
resetListings,
6+
hasPositiveTrustline,
7+
} = require('../index');
8+
9+
describe('LeaseFlow Backend API', () => {
10+
beforeEach(() => {
11+
resetListings();
12+
global.fetch = jest.fn();
13+
});
14+
15+
afterEach(() => {
16+
jest.resetAllMocks();
17+
});
18+
219

320
const { createApp } = require('../index');
421
const { loadConfig } = require('../src/config');
@@ -87,6 +104,187 @@ describe('LeaseFlow Backend API', () => {
87104
});
88105
});
89106

107+
it('should require listing fields on POST /listings', async () => {
108+
const response = await request(app).post('/listings').send({ lister: 'GABC' });
109+
110+
expect(response.status).toBe(400);
111+
expect(response.body).toEqual({
112+
error: 'Missing required fields',
113+
required: ['lister', 'assetCode', 'assetIssuer', 'price'],
114+
});
115+
expect(listings).toHaveLength(0);
116+
});
117+
118+
it('should create a listing when Horizon shows a positive trustline', async () => {
119+
global.fetch.mockResolvedValue({
120+
ok: true,
121+
status: 200,
122+
json: async () => ({
123+
balances: [
124+
{
125+
asset_type: 'credit_alphanum12',
126+
asset_code: 'LEASE-NFT-1',
127+
asset_issuer: 'GISSUER123',
128+
balance: '1.0000000',
129+
},
130+
],
131+
}),
132+
});
133+
134+
const response = await request(app).post('/listings').send({
135+
lister: 'GLISTER123',
136+
assetCode: 'LEASE-NFT-1',
137+
assetIssuer: 'GISSUER123',
138+
price: '1500',
139+
metadata: { leaseId: 'lease-001' },
140+
});
141+
142+
expect(response.status).toBe(201);
143+
expect(response.body.listing).toMatchObject({
144+
lister: 'GLISTER123',
145+
assetCode: 'LEASE-NFT-1',
146+
assetIssuer: 'GISSUER123',
147+
price: '1500',
148+
metadata: { leaseId: 'lease-001' },
149+
});
150+
expect(listings).toHaveLength(1);
151+
expect(global.fetch).toHaveBeenCalledWith(
152+
'https://horizon.stellar.org/accounts/GLISTER123'
153+
);
154+
});
155+
156+
it('should reject listings when the trustline is missing', async () => {
157+
global.fetch.mockResolvedValue({
158+
ok: true,
159+
status: 200,
160+
json: async () => ({
161+
balances: [
162+
{
163+
asset_type: 'native',
164+
balance: '100.0',
165+
},
166+
],
167+
}),
168+
});
169+
170+
const response = await request(app).post('/listings').send({
171+
lister: 'GLISTER456',
172+
assetCode: 'LEASE-NFT-2',
173+
assetIssuer: 'GISSUER456',
174+
price: '2200',
175+
});
176+
177+
expect(response.status).toBe(403);
178+
expect(response.body).toEqual({
179+
error: 'Lister does not own the NFT on-chain',
180+
reason: 'TRUSTLINE_MISSING_OR_EMPTY',
181+
});
182+
expect(listings).toHaveLength(0);
183+
});
184+
185+
it('should reject listings when the trustline exists with zero balance', async () => {
186+
global.fetch.mockResolvedValue({
187+
ok: true,
188+
status: 200,
189+
json: async () => ({
190+
balances: [
191+
{
192+
asset_type: 'credit_alphanum12',
193+
asset_code: 'LEASE-NFT-3',
194+
asset_issuer: 'GISSUER789',
195+
balance: '0.0000000',
196+
},
197+
],
198+
}),
199+
});
200+
201+
const response = await request(app).post('/listings').send({
202+
lister: 'GLISTER789',
203+
assetCode: 'LEASE-NFT-3',
204+
assetIssuer: 'GISSUER789',
205+
price: '3000',
206+
});
207+
208+
expect(response.status).toBe(403);
209+
expect(response.body.reason).toBe('TRUSTLINE_MISSING_OR_EMPTY');
210+
expect(listings).toHaveLength(0);
211+
});
212+
213+
it('should reject listings when Horizon account lookup returns 404', async () => {
214+
global.fetch.mockResolvedValue({
215+
ok: false,
216+
status: 404,
217+
});
218+
219+
const response = await request(app).post('/listings').send({
220+
lister: 'GMISSINGACCOUNT',
221+
assetCode: 'LEASE-NFT-4',
222+
assetIssuer: 'GISSUER404',
223+
price: '3500',
224+
});
225+
226+
expect(response.status).toBe(403);
227+
expect(response.body.reason).toBe('ACCOUNT_NOT_FOUND');
228+
expect(listings).toHaveLength(0);
229+
});
230+
231+
it('should surface Horizon failures without inserting a listing', async () => {
232+
global.fetch.mockResolvedValue({
233+
ok: false,
234+
status: 503,
235+
});
236+
237+
const response = await request(app).post('/listings').send({
238+
lister: 'GUPSTREAMFAIL',
239+
assetCode: 'LEASE-NFT-5',
240+
assetIssuer: 'GISSUER500',
241+
price: '4100',
242+
});
243+
244+
expect(response.status).toBe(502);
245+
expect(response.body).toEqual({
246+
error: 'Unable to verify ownership against Horizon',
247+
details: 'Horizon lookup failed with status 503',
248+
});
249+
expect(listings).toHaveLength(0);
250+
});
251+
252+
it('should detect matching trustlines with positive balances only', () => {
253+
expect(
254+
hasPositiveTrustline(
255+
[
256+
{
257+
asset_type: 'credit_alphanum12',
258+
asset_code: 'LEASE-NFT-6',
259+
asset_issuer: 'GISSUERTRUST',
260+
balance: '1.0000000',
261+
},
262+
{
263+
asset_type: 'credit_alphanum12',
264+
asset_code: 'LEASE-NFT-6',
265+
asset_issuer: 'GISSUERTRUST',
266+
balance: '0.0000000',
267+
},
268+
],
269+
'LEASE-NFT-6',
270+
'GISSUERTRUST'
271+
)
272+
).toBe(true);
273+
274+
expect(
275+
hasPositiveTrustline(
276+
[
277+
{
278+
asset_type: 'credit_alphanum12',
279+
asset_code: 'LEASE-NFT-6',
280+
asset_issuer: 'GISSUERTRUST',
281+
balance: '0.0000000',
282+
},
283+
],
284+
'LEASE-NFT-6',
285+
'GISSUERTRUST'
286+
)
287+
).toBe(false);
90288
it('generates a proposal for an active lease expiring in 60 days', () => {
91289
seedEligibleLease();
92290

0 commit comments

Comments
 (0)