Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions v2/api-validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"generate-openapi-client": "(cd ../openapi; ./lint-openapi.sh) && NODE_ENV=development ts-node generate-openapi-client.ts",
"make-unified-openapi": "NODE_ENV=development ts-node make-unified-openapi.ts",
"build-docs": "npx @redocly/cli build-docs ../openapi/fb-unified-openapi.yaml --output ../openapi/docs.html",
"build-docs-docker": "docker run --rm -v \"$(pwd)/..:/spec\" redocly/cli build-docs /spec/openapi/fb-unified-openapi.yaml --output /spec/openapi/docs.html",
"download-server-capabilities": "NODE_ENV=test ts-node download-server-capabilities.ts | pino-pretty"
},
"overrides": {
Expand Down
2 changes: 1 addition & 1 deletion v2/api-validator/src/client/generated/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export type { CollateralWithdrawalTransactionRequest } from './models/Collateral
export type { CollateralWithdrawalTransactions } from './models/CollateralWithdrawalTransactions';
export { CollateralWithdrawalTransactionStatus } from './models/CollateralWithdrawalTransactionStatus';
export type { CommonCapabilityRequirements } from './models/CommonCapabilityRequirements';
export type { CommonRamp } from './models/CommonRamp';
export { CommonRamp } from './models/CommonRamp';
export type { CommonRampRequestProperties } from './models/CommonRampRequestProperties';
export { ContractBasedToken } from './models/ContractBasedToken';
export type { ConversionPairIdQueryParam } from './models/ConversionPairIdQueryParam';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export namespace BadRequestError {
UNSUPPORTED_TRANSFER_METHOD = 'unsupported-transfer-method',
TRANSFER_DESTINATION_NOT_ALLOWED = 'transfer-destination-not-allowed',
UNSUPPORTED_RAMP_METHOD = 'unsupported-ramp-method',
UNSUPPORTED_SOURCE_ASSET = 'unsupported-source-asset',
UNSUPPORTED_DESTINATION_ASSET = 'unsupported-destination-asset',
AMOUNT_BELOW_MINIMUM = 'amount-below-minimum',
PII_MISSING = 'pii-missing',
UNSUPPORTED_EXTERNAL_SOURCE = 'unsupported-external-source',
UNSUPPORTED_REGION = 'unsupported-region',
DESTINATION_NOT_WHITELISTED = 'destination-not-whitelisted',
}


Expand Down
17 changes: 17 additions & 0 deletions v2/api-validator/src/client/generated/models/CommonRamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,22 @@ export type CommonRamp = {
* Ramp expiration time.
*/
expiresAt: string;
failureReason?: CommonRamp.failureReason;
};

export namespace CommonRamp {

export enum failureReason {
UNSUPPORTED_RAMP_METHOD = 'unsupported-ramp-method',
UNSUPPORTED_SOURCE_ASSET = 'unsupported-source-asset',
UNSUPPORTED_DESTINATION_ASSET = 'unsupported-destination-asset',
AMOUNT_BELOW_MINIMUM = 'amount-below-minimum',
PII_MISSING = 'pii-missing',
UNSUPPORTED_EXTERNAL_SOURCE = 'unsupported-external-source',
UNSUPPORTED_REGION = 'unsupported-region',
DESTINATION_NOT_WHITELISTED = 'destination-not-whitelisted',
}


}

Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ import type { PositiveAmount } from './PositiveAmount';
export type CommonRampRequestProperties = {
idempotencyKey: string;
amount: PositiveAmount;
/**
* Optional customer reference identifier for tracking purposes
*/
customerReferenceId?: string;
};

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
/* eslint-disable */

/**
* No properties. Specifying an empty objects will cause the user identification headers (X-FBAPI-INITIATED-BY, X-FBAPI-APPROVED-BY, X-FBAPI-SIGNED-BY) to be included in requests.
* No properties. Specifying an empty object will cause the user identification headers (X-FBAPI-INITIATED-BY, X-FBAPI-APPROVED-BY, X-FBAPI-SIGNED-BY) to be included in requests.
*/
export type UserIdentificationRequirement = Record<string, any>;
38 changes: 34 additions & 4 deletions v2/api-validator/src/server/controllers/ramps-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import {
InteracAddress,
InteracAddressPaymentInstruction,
InteracCapability,
InternalLedgerAddressRamp,
InternalLedgerCapability,
LocalBankTransferAddress,
LocalBankTransferAddressPaymentInstruction,
LocalBankTransferCapability,
Expand Down Expand Up @@ -64,6 +62,28 @@ export class UnsupportedRampMethod extends XComError {
}
}

export class UnsupportedBaseAssetError extends XComError {
constructor() {
super('The base asset is not supported by the provider');
}
}

export class UnsupportedQuoteAssetError extends XComError {
constructor() {
super('The quote asset is not supported by the provider');
}
}

export class AmountBelowMinimumError extends XComError {
constructor() {
super('The requested amount is below the provider minimum');
}
}

// Sentinel asset IDs used by tests to trigger specific error responses
export const SENTINEL_UNSUPPORTED_SOURCE_ASSET_ID = 'unsupported-source-asset-sentinel';
export const SENTINEL_UNSUPPORTED_DESTINATION_ASSET_ID = 'unsupported-destination-asset-sentinel';

export class RampsController {
private readonly rampsRepository = new Repository<Ramp>();
private readonly rampMethodRepository = new Repository<RampMethod>();
Expand Down Expand Up @@ -102,6 +122,18 @@ export class RampsController {
}

private validateRampRequest(ramp: RampRequest) {
if ('assetId' in ramp.from.asset && ramp.from.asset.assetId === SENTINEL_UNSUPPORTED_SOURCE_ASSET_ID) {
throw new UnsupportedBaseAssetError();
}

if ('assetId' in ramp.to.asset && ramp.to.asset.assetId === SENTINEL_UNSUPPORTED_DESTINATION_ASSET_ID) {
throw new UnsupportedQuoteAssetError();
}

if (parseFloat(ramp.amount) <= 0) {
throw new AmountBelowMinimumError();
}

if (
!AssetsController.isKnownAsset(ramp.from.asset) ||
!AssetsController.isKnownAsset(ramp.to.asset)
Expand Down Expand Up @@ -219,8 +251,6 @@ function getTransferMethod(transferMethod: FiatCapability['transferMethod']): Fi
return fakeSchemaObject('PayIdAddress') as PayIdAddress;
case InteracCapability.transferMethod.INTERAC:
return fakeSchemaObject('InteracAddressPaymentInstruction') as InteracAddressPaymentInstruction;
case InternalLedgerCapability.transferMethod.INTERNAL_LEDGER:
return fakeSchemaObject('InternalLedgerAddressRamp') as InternalLedgerAddressRamp;
default:
throw new XComError('Invalid transfer method', { transferMethod });
}
Expand Down
30 changes: 30 additions & 0 deletions v2/api-validator/src/server/handlers/ramps-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
} from '../../client/generated';
import { ControllersContainer } from '../controllers/controllers-container';
import {
AmountBelowMinimumError,
RampNotFoundError,
RampsController,
UnsupportedBaseAssetError,
UnsupportedQuoteAssetError,
UnsupportedRampMethod,
} from '../controllers/ramps-controller';
import {
Expand Down Expand Up @@ -132,6 +135,33 @@ export async function createRamp(
createRampIdempotencyHandler.add(body, 400, response);
return ErrorFactory.badRequest(reply, response);
}
if (err instanceof UnsupportedBaseAssetError) {
const response = {
message: err.message,
errorType: BadRequestError.errorType.UNSUPPORTED_SOURCE_ASSET,
requestPart: RequestPart.BODY,
};
createRampIdempotencyHandler.add(body, 400, response);
return ErrorFactory.badRequest(reply, response);
}
if (err instanceof UnsupportedQuoteAssetError) {
const response = {
message: err.message,
errorType: BadRequestError.errorType.UNSUPPORTED_DESTINATION_ASSET,
requestPart: RequestPart.BODY,
};
createRampIdempotencyHandler.add(body, 400, response);
return ErrorFactory.badRequest(reply, response);
}
if (err instanceof AmountBelowMinimumError) {
const response = {
message: err.message,
errorType: BadRequestError.errorType.AMOUNT_BELOW_MINIMUM,
requestPart: RequestPart.BODY,
};
createRampIdempotencyHandler.add(body, 400, response);
return ErrorFactory.badRequest(reply, response);
}
throw err;
}
}
Expand Down
56 changes: 56 additions & 0 deletions v2/api-validator/src/server/http-error-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,59 @@ export function idempotencyKeyReuse(reply: FastifyReply): FastifyReply {
};
return reply.code(400).send(errorData);
}

export function unsupportedBaseAsset(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'The base asset is not supported by the provider',
errorType: BadRequestError.errorType.UNSUPPORTED_SOURCE_ASSET,
};
return reply.code(400).send(errorData);
}

export function unsupportedQuoteAsset(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'The quote asset is not supported by the provider',
errorType: BadRequestError.errorType.UNSUPPORTED_DESTINATION_ASSET,
};
return reply.code(400).send(errorData);
}

export function amountBelowMinimum(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'The requested amount is below the provider minimum',
errorType: BadRequestError.errorType.AMOUNT_BELOW_MINIMUM,
};
return reply.code(400).send(errorData);
}

export function piiMissing(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'Required KYC / PII information is missing',
errorType: BadRequestError.errorType.PII_MISSING,
};
return reply.code(400).send(errorData);
}

export function unsupportedExternalSource(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'Provider requires source to be internal to Fireblocks for screening',
errorType: BadRequestError.errorType.UNSUPPORTED_EXTERNAL_SOURCE,
};
return reply.code(400).send(errorData);
}

export function unsupportedRegion(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'Provider does not support orders from the customer region',
errorType: BadRequestError.errorType.UNSUPPORTED_REGION,
};
return reply.code(400).send(errorData);
}

export function destinationNotWhitelisted(reply: FastifyReply): FastifyReply {
const errorData: BadRequestError = {
message: 'The destination address must be whitelisted with the provider first',
errorType: BadRequestError.errorType.DESTINATION_NOT_WHITELISTED,
};
return reply.code(400).send(errorData);
}
54 changes: 54 additions & 0 deletions v2/api-validator/tests/server-tests/ramps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import config from '../../src/config';
import { AssetsDirectory } from '../utils/assets-directory';
import { getAllCapableAccountIds, hasCapability } from '../utils/capable-accounts';
import { getResponsePerIdMapping } from '../utils/response-per-id-mapping';
import {
SENTINEL_UNSUPPORTED_SOURCE_ASSET_ID,
SENTINEL_UNSUPPORTED_DESTINATION_ASSET_ID,
} from '../../src/server/controllers/ramps-controller';

const noRampsCapability = !hasCapability('ramps');
const accountIds = getAllCapableAccountIds('ramps');
Expand Down Expand Up @@ -578,6 +582,56 @@ describe.skipIf(noRampsCapability)('Ramps', () => {
expect(error.body.errorType).toBe(BadRequestError.errorType.UNSUPPORTED_RAMP_METHOD);
expect(error.body.requestPart).toBe(RequestPart.BODY);
});

it('should fail with unsupported-source-asset when from asset is not supported', async () => {
const requestBody = rampRequestFromMethod({
id: randomUUID(),
from: {
asset: { assetId: SENTINEL_UNSUPPORTED_SOURCE_ASSET_ID },
transferMethod: PublicBlockchainCapability.transferMethod.PUBLIC_BLOCKCHAIN,
},
to: {
asset: { cryptocurrencySymbol: CryptocurrencySymbol.ETH },
transferMethod: PublicBlockchainCapability.transferMethod.PUBLIC_BLOCKCHAIN,
},
});

const error = await getCreateRampFailureResult(requestBody);

expect(error.status).toBe(400);
expect(error.body.errorType).toBe(BadRequestError.errorType.UNSUPPORTED_SOURCE_ASSET);
expect(error.body.requestPart).toBe(RequestPart.BODY);
});

it('should fail with unsupported-destination-asset when to asset is not supported', async () => {
const requestBody = rampRequestFromMethod({
id: randomUUID(),
from: {
asset: { cryptocurrencySymbol: CryptocurrencySymbol.ETH },
transferMethod: PublicBlockchainCapability.transferMethod.PUBLIC_BLOCKCHAIN,
},
to: {
asset: { assetId: SENTINEL_UNSUPPORTED_DESTINATION_ASSET_ID },
transferMethod: PublicBlockchainCapability.transferMethod.PUBLIC_BLOCKCHAIN,
},
});

const error = await getCreateRampFailureResult(requestBody);

expect(error.status).toBe(400);
expect(error.body.errorType).toBe(BadRequestError.errorType.UNSUPPORTED_DESTINATION_ASSET);
expect(error.body.requestPart).toBe(RequestPart.BODY);
});

it('should fail with amount-below-minimum when amount is zero', async () => {
const requestBody = { ...rampRequestFromMethod(capability), amount: '0' };

const error = await getCreateRampFailureResult(requestBody);

expect(error.status).toBe(400);
expect(error.body.errorType).toBe(BadRequestError.errorType.AMOUNT_BELOW_MINIMUM);
expect(error.body.requestPart).toBe(RequestPart.BODY);
});
});

describe('Idempotency', () => {
Expand Down
Loading
Loading