Skip to content

Commit

Permalink
feat: add accounts/compare endpoint (#1597)
Browse files Browse the repository at this point in the history
* feat: add accounts/compare endpoint

* format the query param as an array

* accept comma separated addresses in query param
- Refactored Middleware
- Updated Controller
- Fixed & updated docs
  • Loading branch information
Imod7 authored Feb 21, 2025
1 parent 6c97553 commit 191e68a
Show file tree
Hide file tree
Showing 17 changed files with 353 additions and 1 deletion.
61 changes: 61 additions & 0 deletions docs/src/openapi-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,39 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/AccountValidation'
/accounts/compare:
get:
tags:
- accounts
summary: Compares up to 30 SS58 addresses.
description: Returns if the given addresses are equal or not, along with details of each address.
Equality is determined by comparing the accountId/publicKey of each address.
operationId: accountCompare
parameters:
- name: addresses
in: query
description: An array or a comma separated string of SS58 addresses. Provide up to 30 addresses
using one of these formats `?addresses=...&addresses=...` or `?addresses[]=...&addresses[]=...`
or `?addresses=...,...`.
required: true
schema:
type: array
items:
type: string
description: An array of SS58 addresses.
responses:
"200":
description: successfully compared at least two SS58 addresses.
content:
application/json:
schema:
$ref: '#/components/schemas/AccountCompare'
"400":
description: Invalid Address
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/blocks:
get:
tags:
Expand Down Expand Up @@ -2744,6 +2777,18 @@ components:
locks
items:
$ref: '#/components/schemas/BalanceLock'
AccountCompare:
type: object
properties:
areEqual:
type: boolean
description: Whether the given SS58 addresses are equal or not. Equality is determined by comparing
the accountId/publicKey of each address.
addresses:
type: array
description: An array that contains detailed information for each of the queried SS58 addresses.
items:
$ref: '#/components/schemas/AddressDetails'
AccountConvert:
type: object
properties:
Expand Down Expand Up @@ -2894,6 +2939,22 @@ components:
type: array
items:
$ref: '#/components/schemas/VestingSchedule'
AddressDetails:
type: object
properties:
ss58Format:
type: string
description: The queried SS58 address.
ss58Prefix:
type: string
description: SS58 prefix of the given address.
format: unsignedInteger
network:
type: string
description: The network based on which the given address is encoded.
publicKey:
type: string
description: The account ID/Public Key (hex) of the queried SS58 address.
AssetsBalance:
type: object
properties:
Expand Down
1 change: 1 addition & 0 deletions src/chains-config/assetHubKusamaControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const assetHubKusamaControllers: ControllerConfig = {
controllers: [
'AccountsAssets',
'AccountsBalanceInfo',
'AccountsCompare',
'AccountsValidate',
'Blocks',
'BlocksExtrinsics',
Expand Down
1 change: 1 addition & 0 deletions src/chains-config/assetHubPolkadotControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const assetHubPolkadotControllers: ControllerConfig = {
controllers: [
'AccountsAssets',
'AccountsBalanceInfo',
'AccountsCompare',
'AccountsProxyInfo',
'AccountsValidate',
'Blocks',
Expand Down
1 change: 1 addition & 0 deletions src/chains-config/assetHubWestendControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const assetHubWestendControllers: ControllerConfig = {
controllers: [
'AccountsAssets',
'AccountsBalanceInfo',
'AccountsCompare',
'AccountsProxyInfo',
'AccountsPoolAssets',
'AccountsValidate',
Expand Down
1 change: 1 addition & 0 deletions src/chains-config/kusamaControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { initLRUCache, QueryFeeDetailsCache } from './cache';
export const kusamaControllers: ControllerConfig = {
controllers: [
'AccountsBalanceInfo',
'AccountsCompare',
'AccountsConvert',
'AccountsProxyInfo',
'AccountsStakingInfo',
Expand Down
1 change: 1 addition & 0 deletions src/chains-config/polkadotControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { initLRUCache, QueryFeeDetailsCache } from './cache';
export const polkadotControllers: ControllerConfig = {
controllers: [
'AccountsBalanceInfo',
'AccountsCompare',
'AccountsConvert',
'AccountsProxyInfo',
'AccountsStakingInfo',
Expand Down
1 change: 1 addition & 0 deletions src/chains-config/westendControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { initLRUCache, QueryFeeDetailsCache } from './cache';
export const westendControllers: ControllerConfig = {
controllers: [
'AccountsBalanceInfo',
'AccountsCompare',
'AccountsConvert',
'AccountsProxyInfo',
'AccountsStakingInfo',
Expand Down
49 changes: 49 additions & 0 deletions src/controllers/accounts/AccountsCompareController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2017-2025 Parity Technologies (UK) Ltd.
// This file is part of Substrate API Sidecar.
//
// Substrate API Sidecar is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

import { ApiPromise } from '@polkadot/api';
import { RequestHandler } from 'express';
import { BadRequest } from 'http-errors';

import { validateAddressQueryParam } from '../../middleware';
import { AccountsCompareService } from '../../services/accounts';
import { ICompareQueryParams } from '../../types/requests';
import AbstractController from '../AbstractController';

export default class AccountsCompareController extends AbstractController<AccountsCompareService> {
constructor(api: ApiPromise) {
super(api, '/accounts/compare', new AccountsCompareService(api));
this.initRoutes();
}

protected initRoutes(): void {
this.router.use(this.path, validateAddressQueryParam);
this.safeMountAsyncGetHandlers([['', this.accountCompare]]);
}

private accountCompare: RequestHandler<unknown, unknown, ICompareQueryParams> = ({ query: { addresses } }, res) => {
const addressesArray = Array.isArray(addresses)
? (addresses as string[] | string)
: (addresses as string).split(',');

// Check that the number of addresses is less than 30.
if (addressesArray.length > 30) {
throw new BadRequest(`Please limit the amount of address parameters to 30.`);
}

AccountsCompareController.sanitizedSend(res, this.service.accountCompare(addressesArray as string[]));
};
}
1 change: 1 addition & 0 deletions src/controllers/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

export { default as AccountsAssets } from './AccountsAssetsController';
export { default as AccountsBalanceInfo } from './AccountsBalanceInfoController';
export { default as AccountsCompare } from './AccountsCompareController';
export { default as AccountsConvert } from './AccountsConvertController';
export { default as AccountsPoolAssets } from './AccountsPoolAssetsController';
export { default as AccountsProxyInfo } from './AccountsProxyInfoController';
Expand Down
2 changes: 2 additions & 0 deletions src/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import {
AccountsAssets,
AccountsBalanceInfo,
AccountsCompare,
AccountsConvert,
AccountsPoolAssets,
AccountsProxyInfo,
Expand Down Expand Up @@ -58,6 +59,7 @@ export const controllers = {
BlocksRawExtrinsics,
AccountsAssets,
AccountsBalanceInfo,
AccountsCompare,
AccountsConvert,
AccountsPoolAssets,
AccountsProxyInfo,
Expand Down
5 changes: 4 additions & 1 deletion src/middleware/validate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

export { validateAddressMiddleware as validateAddress } from './validateAddressMiddleware';
export {
validateAddressMiddleware as validateAddress,
validateAddressQueryParamMiddleware as validateAddressQueryParam,
} from './validateAddressMiddleware';
export { validateBooleanMiddleware as validateBoolean } from './validateBooleanMiddleware';
50 changes: 50 additions & 0 deletions src/middleware/validate/validateAddressMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,56 @@ export const validateAddressMiddleware: RequestHandler = (req, _res, next) => {
return next();
};

/**
* Express Middleware to validate that an `:address` given as a query parameter is properly formatted.
* It also does the following checks:
* - that the query parameter name is `addresses`.
* - that the query parameter is given as an array of addresses or a string of comma separated addresses.
* - validates all addresses found in the query parameter.
*/
export const validateAddressQueryParamMiddleware: RequestHandler = (req, _res, next) => {
// Check that the query parameter name is `addresses`.
const queryParamKey = Object.keys(req.query);
const validParamKey = queryParamKey.filter((key) => /^addresses$/.test(key));

if (validParamKey.length != queryParamKey.length || validParamKey.length === 0) {
return next(new BadRequest(`Please use the query parameter key 'addresses' to provide the addresses values.`));
}

// Check that the addresses are valid. Same check as in the `validateAddressMiddleware` but for query parameters.
for (const param of validParamKey) {
const addresses = req.query[param];
// Check if the address is a string of comma separated addresses or an array of addresses.
if (typeof addresses === 'string') {
const addressesArray = addresses.split(',');

if (addressesArray.length <= 1) {
return next(new BadRequest(`At least two addresses are required for comparison.`));
}

for (const address of addressesArray) {
const [isValid, error] = checkAddress(address);
if (!isValid && error) {
return next(new BadRequest(`Invalid ${param}: ${error}`));
}
}
} else if (Array.isArray(addresses)) {
if (addresses.length <= 1) {
return next(new BadRequest(`At least two addresses are required for comparison.`));
}

for (const address of addresses) {
const [isValid, error] = checkAddress(address as string);
if (!isValid && error) {
return next(new BadRequest(`Invalid ${param}: ${error}`));
}
}
}
}

return next();
};

/**
* Verify that an address is a valid substrate address.
*
Expand Down
92 changes: 92 additions & 0 deletions src/services/accounts/AccountsCompareService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2017-2025 Parity Technologies (UK) Ltd.
// This file is part of Substrate API Sidecar.
//
// Substrate API Sidecar is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

import { ApiPromise } from '@polkadot/api';

import { sanitizeNumbers } from '../../sanitize';
import { defaultMockApi } from '../test-helpers/mock';
import { AccountsCompareService } from './AccountsCompareService';

const mockApi = {
...defaultMockApi,
} as unknown as ApiPromise;
const validateService = new AccountsCompareService(mockApi);

describe('Compare two addresses', () => {
it('Should compare the two addresses and return that they are not equal, along with the details of each address.', () => {
const expectedResponse = {
areEqual: false,
addresses: [
{
ss58Format: '7P9y4pUmp5SJ4gKK3rFw6TvR24eqW7jVoyDs8LPunRYcHuzi',
ss58Prefix: '63',
network: 'hydradx',
publicKey: '0xf5e51345031c9ba63ae27605d028ad959ba7f4eba289fb16368ddfab2788936f',
},
{
ss58Format: '1kFahMfeRf4XJbApbczHczTTioF9NzKBe9D8g5xFw9JAVdE',
ss58Prefix: '0',
network: 'polkadot',
publicKey: '0x20fca2e352c4906760cb18af6a36e42169e489246ed739307e01b4922a5d119f',
},
],
};
const address1 = '7P9y4pUmp5SJ4gKK3rFw6TvR24eqW7jVoyDs8LPunRYcHuzi';
const address2 = '1kFahMfeRf4XJbApbczHczTTioF9NzKBe9D8g5xFw9JAVdE';

expect(sanitizeNumbers(validateService.accountCompare([address1, address2]))).toStrictEqual(expectedResponse);
});

it('Should compare the four addresses and return that they are equal, along with the details of each address.', () => {
const expectedResponse = {
areEqual: true,
addresses: [
{
ss58Format: '1jeB5w8XyBADtgmVmwk2stWpTyfTVWEgLo85tF7gYVxnmSw',
ss58Prefix: '0',
network: 'polkadot',
publicKey: '0x20857206fde63ea508a317a77bc1ca2a795c978533b71fc7bc21d352d832637c',
},
{
ss58Format: 'DJxh51wJYvcY1VhJqhnngRN7SGFZrmH4DuPKFXicFgwMQCT',
ss58Prefix: '2',
network: 'kusama',
publicKey: '0x20857206fde63ea508a317a77bc1ca2a795c978533b71fc7bc21d352d832637c',
},
{
ss58Format: 'WfwU3e9TRYo1Bi62SLrvPDe3th8jQeqd3ZnAJTczpUQCNZ8',
ss58Prefix: '5',
network: 'astar',
publicKey: '0x20857206fde63ea508a317a77bc1ca2a795c978533b71fc7bc21d352d832637c',
},
{
ss58Format: 'cTbj3BYqiRXAF7YvyDtJHV4hNqRnbHMoz7umz6vTg4tUjCY',
ss58Prefix: '6',
network: 'bifrost',
publicKey: '0x20857206fde63ea508a317a77bc1ca2a795c978533b71fc7bc21d352d832637c',
},
],
};
const address1 = '1jeB5w8XyBADtgmVmwk2stWpTyfTVWEgLo85tF7gYVxnmSw';
const address2 = 'DJxh51wJYvcY1VhJqhnngRN7SGFZrmH4DuPKFXicFgwMQCT';
const address3 = 'WfwU3e9TRYo1Bi62SLrvPDe3th8jQeqd3ZnAJTczpUQCNZ8';
const address4 = 'cTbj3BYqiRXAF7YvyDtJHV4hNqRnbHMoz7umz6vTg4tUjCY';

expect(sanitizeNumbers(validateService.accountCompare([address1, address2, address3, address4]))).toStrictEqual(
expectedResponse,
);
});
});
Loading

0 comments on commit 191e68a

Please sign in to comment.