Skip to content

Commit f6cbd41

Browse files
Merge pull request #1615 from aladi11isah/feature/1502-1503-ip-scoping-path-payments
Feature/1502 1503 ip scoping path payments
2 parents 4941487 + 0dfd56a commit f6cbd41

7 files changed

Lines changed: 79 additions & 11 deletions

File tree

src/middleware/apiKey.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ const requireApiKey = async (req, res, next) => {
195195
return res.status(403).json({
196196
success: false,
197197
error: {
198-
code: 'FORBIDDEN',
198+
code: 'API_KEY_IP_RESTRICTED',
199199
message: 'IP address not permitted for this API key',
200200
requestId: req.id,
201201
timestamp: new Date().toISOString(),

src/routes/apiKeys.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ router.post('/', requireAdmin(), apiKeyCreateSchema, payloadSizeLimiter(ENDPOINT
9494
}
9595

9696
// Validate scopes
97-
const scopeValidation = validateScopes(scopes);
97+
const scopeValidation = validateScopes(scopes || []);
9898
if (!scopeValidation.valid) {
9999
throw new ValidationError(`Invalid scopes: ${scopeValidation.errors.join('; ')}`);
100100
}

src/routes/donations/create.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ router.post('/', rotationLockMiddleware(), payloadSizeLimiter(ENDPOINT_LIMITS.si
283283
return await processCustodialDonation(req, res, next);
284284
}
285285

286-
const { amount, currency, donor, recipient, memo, memoType, notes, tags, encryptMemo, anonymous, sourceAsset, sourceAmount } = req.body;
286+
const { amount, currency, donor, recipient, memo, memoType, notes, tags, encryptMemo, anonymous, sourceAsset, sourceAmount, sendAsset, receiveAsset, slippageTolerance } = req.body;
287287

288288
if (!amount || !recipient) {
289289
throw new ValidationError('Missing required fields: amount, recipient', null, ERROR_CODES.MISSING_REQUIRED_FIELD);
@@ -314,6 +314,32 @@ router.post('/', rotationLockMiddleware(), payloadSizeLimiter(ENDPOINT_LIMITS.si
314314
}
315315
}
316316

317+
let normalizedSendAsset = null;
318+
let normalizedReceiveAsset = null;
319+
let normalizedSlippageTolerance = null;
320+
if (sendAsset || receiveAsset) {
321+
if (!sendAsset || !receiveAsset) {
322+
return res.status(400).json({
323+
success: false,
324+
error: { code: 'VALIDATION_ERROR', message: 'Both sendAsset and receiveAsset must be provided for cross-asset donations' }
325+
});
326+
}
327+
normalizedSendAsset = parseAssetInput(sendAsset, 'sendAsset');
328+
normalizedReceiveAsset = parseAssetInput(receiveAsset, 'receiveAsset');
329+
330+
if (slippageTolerance !== undefined && slippageTolerance !== null) {
331+
if (typeof slippageTolerance !== 'number' || slippageTolerance < 0 || slippageTolerance > 1) {
332+
return res.status(400).json({
333+
success: false,
334+
error: { code: 'VALIDATION_ERROR', message: 'slippageTolerance must be a number between 0 and 1' }
335+
});
336+
}
337+
normalizedSlippageTolerance = slippageTolerance;
338+
} else {
339+
normalizedSlippageTolerance = 0.01;
340+
}
341+
}
342+
317343
if (memo || memoType) {
318344
const memoValidator = require('../../utils/memoValidator');
319345
const memoValidation = memoValidator.validateWithType(memo || '', memoType || 'text');
@@ -357,6 +383,9 @@ router.post('/', rotationLockMiddleware(), payloadSizeLimiter(ENDPOINT_LIMITS.si
357383
memo,
358384
sourceAsset: normalizedSourceAsset,
359385
sourceAmount: sourceAmountValidation ? sourceAmountValidation.value : undefined,
386+
sendAsset: normalizedSendAsset,
387+
receiveAsset: normalizedReceiveAsset,
388+
slippageTolerance: normalizedSlippageTolerance,
360389
memoType: memoType || 'text',
361390
notes,
362391
tags,

src/routes/donations/helpers.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ const createDonationSchema = validateSchema({
7373
notes: { type: 'string', required: false, nullable: true },
7474
tags: { type: 'array', required: false, nullable: true },
7575
sourceAsset: { type: 'string', required: false, nullable: true },
76-
sourceAmount: { types: ['number', 'numberString'], required: false, nullable: true }
76+
sourceAmount: { types: ['number', 'numberString'], required: false, nullable: true },
77+
sendAsset: { types: ['string', 'object'], required: false, nullable: true },
78+
receiveAsset: { types: ['string', 'object'], required: false, nullable: true },
79+
slippageTolerance: { type: 'number', required: false, nullable: true }
7780
}
7881
}
7982
});

src/scripts/manageApiKeys.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const commands = {
2222
const name = args.name;
2323
const role = args.role || 'user';
2424
const expiresInDays = args.expires ? parseInt(args.expires, 10) : undefined;
25+
const allowedIps = args['allowed-ips']
26+
? args['allowed-ips'].split(',').map(ip => ip.trim()).filter(ip => ip)
27+
: null;
2528

2629
if (!name) {
2730
console.error('Error: --name is required');
@@ -38,7 +41,8 @@ const commands = {
3841
role,
3942
expiresInDays,
4043
createdBy: 'cli',
41-
metadata: { createdVia: 'cli' }
44+
metadata: { createdVia: 'cli' },
45+
allowedIps,
4246
});
4347

4448
console.log('\n✓ API Key created successfully!\n');
@@ -52,6 +56,9 @@ const commands = {
5256
if (keyInfo.expiresAt) {
5357
console.log('Expires:', new Date(keyInfo.expiresAt).toISOString());
5458
}
59+
if (keyInfo.allowedIps && keyInfo.allowedIps.length > 0) {
60+
console.log('Allowed IPs:', keyInfo.allowedIps.join(', '));
61+
}
5562
console.log('\n⚠️ IMPORTANT: Store this key securely. It will not be shown again.\n');
5663
},
5764

@@ -149,6 +156,8 @@ Commands:
149156
--name <string> Key name (required)
150157
--role <string> Role: admin, user, guest (default: user)
151158
--expires <number> Expiration in days (optional)
159+
--allowed-ips <ips> Comma-separated IP addresses/CIDR ranges (optional)
160+
Example: "192.168.1.0/24,10.0.0.1"
152161
153162
list List all API keys
154163
--status <string> Filter by status: active, deprecated, revoked
@@ -167,6 +176,7 @@ Commands:
167176
168177
Examples:
169178
node src/scripts/manageApiKeys.js create --name "Production API" --role admin --expires 365
179+
node src/scripts/manageApiKeys.js create --name "Office API" --role user --allowed-ips "192.168.1.0/24,10.0.0.1"
170180
node src/scripts/manageApiKeys.js list --status active
171181
node src/scripts/manageApiKeys.js deprecate --id 1
172182
node src/scripts/manageApiKeys.js revoke --id 2

src/services/DonationService.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,9 @@ class DonationService {
710710
anonymous = false,
711711
sourceAsset,
712712
sourceAmount,
713+
sendAsset,
714+
receiveAsset,
715+
slippageTolerance,
713716
validAfter = 0,
714717
validBefore = 0,
715718
memoEnvelope = null,
@@ -801,11 +804,22 @@ class DonationService {
801804
}
802805

803806
const sourceAssetProvided = sourceAsset !== undefined && sourceAsset !== null;
804-
const normalizedDestAsset = DEFAULT_DESTINATION_ASSET;
805-
const normalizedSourceAsset = sourceAssetProvided
807+
const sendAssetProvided = sendAsset !== undefined && sendAsset !== null;
808+
const receiveAssetProvided = receiveAsset !== undefined && receiveAsset !== null;
809+
810+
let normalizedDestAsset = DEFAULT_DESTINATION_ASSET;
811+
let normalizedSourceAsset = sourceAssetProvided
806812
? parseAssetInput(sourceAsset, 'sourceAsset')
807813
: normalizedDestAsset;
814+
815+
// Handle sendAsset/receiveAsset for path payment donations
816+
if (sendAssetProvided && receiveAssetProvided) {
817+
normalizedSourceAsset = parseAssetInput(sendAsset, 'sendAsset');
818+
normalizedDestAsset = parseAssetInput(receiveAsset, 'receiveAsset');
819+
}
820+
808821
const normalizedSourceAmount = sourceAmount ?? xlmAmount;
822+
const normalizedSlippageTolerance = slippageTolerance ?? 0.01;
809823
const sourceSecret = this.resolvePaymentSourceSecret(sanitizedDonor);
810824
let stellarResult = null;
811825
let paymentMethod = 'record_only';
@@ -815,12 +829,15 @@ class DonationService {
815829

816830
if (sourceSecret && sanitizedRecipient) {
817831
await this.checkRecipientAccountExists(sanitizedRecipient);
818-
if (!sourceAssetProvided) {
832+
833+
const isPathPayment = sourceAssetProvided || (sendAssetProvided && receiveAssetProvided && !isSameAsset(normalizedSourceAsset, normalizedDestAsset));
834+
835+
if (!isPathPayment) {
819836
// Set correlation ID on StellarService for this request
820837
if (correlationId) {
821838
this.stellarService.setCorrelationId(correlationId);
822839
}
823-
840+
824841
stellarResult = await this.stellarService.sendDonation({
825842
sourceSecret,
826843
destinationPublic: sanitizedRecipient,
@@ -837,6 +854,11 @@ class DonationService {
837854
this.stellarService.setCorrelationId(correlationId);
838855
}
839856

857+
// Set correlation ID on StellarService for this request
858+
if (correlationId) {
859+
this.stellarService.setCorrelationId(correlationId);
860+
}
861+
840862
const estimate = await this.stellarService.discoverBestPath({
841863
sourceAsset: normalizedSourceAsset,
842864
sourceAmount: normalizedSourceAmount.toString(),
@@ -851,12 +873,16 @@ class DonationService {
851873
selectedPath = estimate.path || [];
852874
conversionRate = estimate.conversionRate;
853875

876+
// Apply slippage tolerance to minimum destination amount
877+
const estimatedDestAmount = parseFloat(estimate.destAmount);
878+
const minDestAmount = estimatedDestAmount * (1 - normalizedSlippageTolerance);
879+
854880
try {
855881
stellarResult = await this.stellarService.pathPayment(
856882
normalizedSourceAsset,
857883
normalizedSourceAmount.toString(),
858884
normalizedDestAsset,
859-
estimate.destAmount,
885+
minDestAmount.toString(),
860886
selectedPath,
861887
{
862888
sourceSecret,

tests/security/ip-allowlist.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ describe('IP allowlist middleware integration', () => {
189189
});
190190
const res = await request(app).get('/health').set('x-api-key', key);
191191
expect(res.status).toBe(403);
192-
expect(res.body.error.code).toBe('FORBIDDEN');
192+
expect(res.body.error.code).toBe('API_KEY_IP_RESTRICTED');
193193
});
194194

195195
it('allows requests matching a CIDR range that includes loopback', async () => {

0 commit comments

Comments
 (0)