diff --git a/packages/api/README.md b/packages/api/README.md index 4851e285c..17f884fcd 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -712,7 +712,60 @@ Response codes: 200: OK 500: Internal Server Error ``` +--- +### Identity Withdrawals +Return all withdrawals for identity + +_Note: this request does not contain any pagination data in the response_ + +* `limit` cannot be more then 100 +``` +GET /identity/A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb/withdrawals?limit=5 +[ + { + "timestamp": 1729096625509, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "95eiiqMotMvH23f6cv3BPC4ykcHFWTy2g3baCTWZANAs", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729096140465, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "DJzb8nj7JTHwnvAGEGhyFc5hHLFa5Es9WFAyS4HhhNeF", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729096636318, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "E4gbWCQgqrz9DVrzCeDKhr4PVsfp6CeL5DUAYndRVWdk", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729096795042, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "FouX2qY8Eaxj5rSBrH9uxbhAM16ozrUP4sJwdo9pL7Cr", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729097247874, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "9VEpb2aJRnCxfi3LjFXWa1zshkBPfzzHHh5yqEkgqw1t", + "amount": 200000, + "status": 3 + } +] +``` +Response codes: +``` +200: OK +500: Internal Server Error +``` +--- ### Data contracts by Identity Return all data contracts by the given identity diff --git a/packages/api/data_contracts/withdrawals.json b/packages/api/data_contracts/withdrawals.json new file mode 100644 index 000000000..42fdc00e5 --- /dev/null +++ b/packages/api/data_contracts/withdrawals.json @@ -0,0 +1,141 @@ +{ + "version": 0, + "ownerId": "11111111111111111111111111111111", + "id": "4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6", + "$format_version": "0", + "documentSchemas": { + "withdrawal": { + "type": "object", + "indices": [ + { + "name": "identityStatus", + "unique": false, + "properties": [ + { + "$ownerId": "asc" + }, + { + "status": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "identityRecent", + "unique": false, + "properties": [ + { + "$ownerId": "asc" + }, + { + "$updatedAt": "asc" + }, + { + "status": "asc" + } + ] + }, + { + "name": "pooling", + "unique": false, + "properties": [ + { + "status": "asc" + }, + { + "pooling": "asc" + }, + { + "coreFeePerByte": "asc" + }, + { + "$updatedAt": "asc" + } + ] + }, + { + "name": "transaction", + "unique": false, + "properties": [ + { + "status": "asc" + }, + { + "transactionIndex": "asc" + } + ] + } + ], + "required": [ + "$createdAt", + "$updatedAt", + "amount", + "coreFeePerByte", + "pooling", + "outputScript", + "status" + ], + "properties": { + "amount": { + "type": "integer", + "minimum": 1000, + "position": 2, + "description": "The amount to be withdrawn" + }, + "status": { + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "type": "integer", + "position": 6, + "description": "0 - Pending, 1 - Signed, 2 - Broadcasted, 3 - Complete, 4 - Expired" + }, + "pooling": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "position": 4, + "description": "This indicated the level at which Platform should try to pool this transaction" + }, + "outputScript": { + "type": "array", + "maxItems": 25, + "minItems": 23, + "position": 5, + "byteArray": true + }, + "coreFeePerByte": { + "type": "integer", + "maximum": 4294967295, + "minimum": 1, + "position": 3, + "description": "This is the fee that you are willing to spend for this transaction in Duffs/Byte" + }, + "transactionIndex": { + "type": "integer", + "minimum": 1, + "position": 0, + "description": "Sequential index of asset unlock (withdrawal) transaction. Populated when a withdrawal pooled into withdrawal transaction" + }, + "transactionSignHeight": { + "type": "integer", + "minimum": 1, + "position": 1, + "description": "The Core height on which transaction was signed" + } + }, + "description": "Withdrawal document to track underlying withdrawal transactions. Withdrawals should be created with IdentityWithdrawalTransition", + "additionalProperties": false, + "creationRestrictionMode": 2 + } + } +} diff --git a/packages/api/src/DAPI.js b/packages/api/src/DAPI.js index aacbdad10..09dc20554 100644 --- a/packages/api/src/DAPI.js +++ b/packages/api/src/DAPI.js @@ -1,3 +1,4 @@ +const Withdrawal = require('./models/Withdrawal') const { Identifier } = require('dash').PlatformProtocol class DAPI { @@ -24,6 +25,24 @@ class DAPI { return epochsInfo } + async getDocuments (type, dataContractObject, identifier, limit) { + const dataContract = await this.dpp.dataContract.createFromObject(dataContractObject) + + const { documents } = await this.dapi.platform.getDocuments(Identifier.from(dataContractObject.id), type, { + limit, + where: [ + ['$ownerId', '=', Identifier.from(identifier)] + ] + }) + + return documents.map( + (document) => + Withdrawal.fromRaw( + this.dpp.document.createExtendedDocumentFromDocumentBuffer(document, type, dataContract).toJSON() + ) + ) + } + /** * Fetch the version upgrade votes status * @typedef {getContestedState} diff --git a/packages/api/src/constants.js b/packages/api/src/constants.js index d946603f5..df183f578 100644 --- a/packages/api/src/constants.js +++ b/packages/api/src/constants.js @@ -3,6 +3,7 @@ const TenderdashRPC = require('./tenderdashRpc') let genesisTime module.exports = { + WITHDRAWAL_CONTRACT_TYPE: 'withdrawal', EPOCH_CHANGE_TIME: Number(process.env.EPOCH_CHANGE_TIME), TCP_CONNECT_TIMEOUT: Number(process.env.TCP_CONNECT_TIMEOUT), DPNS_CONTRACT: 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec', diff --git a/packages/api/src/controllers/IdentitiesController.js b/packages/api/src/controllers/IdentitiesController.js index 936a4f682..f59468230 100644 --- a/packages/api/src/controllers/IdentitiesController.js +++ b/packages/api/src/controllers/IdentitiesController.js @@ -1,5 +1,6 @@ const IdentitiesDAO = require('../dao/IdentitiesDAO') -const { IDENTITY_CREDIT_WITHDRAWAL } = require('../enums/StateTransitionEnum') +const { WITHDRAWAL_CONTRACT_TYPE } = require('../constants') +const WithdrawalsContract = require('../../data_contracts/withdrawals.json') class IdentitiesController { constructor (knex, dapi) { @@ -77,17 +78,24 @@ class IdentitiesController { getWithdrawalsByIdentity = async (request, response) => { const { identifier } = request.params - const { page = 1, limit = 10, order = 'asc' } = request.query + const { limit = 100 } = request.query + + const documents = await this.dapi.getDocuments(WITHDRAWAL_CONTRACT_TYPE, WithdrawalsContract, identifier, limit) + + const timestamps = documents.map(document => new Date(document.timestamp).toISOString()) - const withdrawals = await this.identitiesDAO.getTransfersByIdentity( - identifier, - Number(page ?? 1), - Number(limit ?? 10), - order ?? 'asc', - IDENTITY_CREDIT_WITHDRAWAL - ) + const txHashes = await this.identitiesDAO.getIdentityWithdrawalsByTimestamps(identifier, timestamps) + + if (documents.length === 0) { + return response.status(404).send({ message: 'not found' }) + } - response.send(withdrawals) + response.send(documents.map(document => ({ + ...document, + hash: txHashes.find( + hash => + new Date(hash.timestamp).toISOString() === new Date(document.timestamp).toISOString())?.hash ?? null + }))) } } diff --git a/packages/api/src/dao/IdentitiesDAO.js b/packages/api/src/dao/IdentitiesDAO.js index dc14b40d2..fd302847a 100644 --- a/packages/api/src/dao/IdentitiesDAO.js +++ b/packages/api/src/dao/IdentitiesDAO.js @@ -4,6 +4,7 @@ const Transaction = require('../models/Transaction') const Document = require('../models/Document') const DataContract = require('../models/DataContract') const PaginatedResultSet = require('../models/PaginatedResultSet') +const { IDENTITY_CREDIT_WITHDRAWAL } = require('../enums/StateTransitionEnum') const { getAliasInfo } = require('../utils') const { base58 } = require('@scure/base') @@ -353,4 +354,16 @@ module.exports = class IdentitiesDAO { return new PaginatedResultSet(rows.map(row => Transfer.fromRow(row)), page, limit, totalCount) } + + getIdentityWithdrawalsByTimestamps = async (identifier, timestamps = []) => { + return this.knex('state_transitions') + .select('state_transitions.hash', 'blocks.timestamp as timestamp') + .whereIn( + 'blocks.timestamp', + timestamps + ) + .andWhere('owner', identifier) + .andWhere('type', IDENTITY_CREDIT_WITHDRAWAL) + .leftJoin('blocks', 'block_hash', 'blocks.hash') + } } diff --git a/packages/api/src/models/Withdrawal.js b/packages/api/src/models/Withdrawal.js new file mode 100644 index 000000000..a96e61b1c --- /dev/null +++ b/packages/api/src/models/Withdrawal.js @@ -0,0 +1,21 @@ +module.exports = class Withdrawal { + timestamp + hash + sender + id + amount + status + + constructor (timestamp, hash, sender, id, amount, status) { + this.timestamp = timestamp ?? null + this.hash = hash ?? null + this.sender = sender ?? null + this.id = id ?? null + this.amount = amount ?? null + this.status = status ?? null + } + + static fromRaw (data = {}) { + return new Withdrawal(data.$createdAt, null, data.$ownerId, data.$id, data.amount, data.status) + } +} diff --git a/packages/api/src/routes.js b/packages/api/src/routes.js index 9d89dffe0..91e5d7340 100644 --- a/packages/api/src/routes.js +++ b/packages/api/src/routes.js @@ -201,7 +201,6 @@ module.exports = ({ method: 'GET', handler: identitiesController.getWithdrawalsByIdentity, schema: { - querystring: { $ref: 'paginationOptions#' }, params: { type: 'object', properties: { diff --git a/packages/api/test/integration/identities.spec.js b/packages/api/test/integration/identities.spec.js index 876d4c587..3b42d4896 100644 --- a/packages/api/test/integration/identities.spec.js +++ b/packages/api/test/integration/identities.spec.js @@ -27,7 +27,145 @@ describe('Identities routes', () => { let transaction let transactions + let dataContractSchema + before(async () => { + dataContractSchema = { + withdrawal: { + type: 'object', + indices: [ + { + name: 'identityStatus', + unique: false, + properties: [ + { + $ownerId: 'asc' + }, + { + status: 'asc' + }, + { + $createdAt: 'asc' + } + ] + }, + { + name: 'identityRecent', + unique: false, + properties: [ + { + $ownerId: 'asc' + }, + { + $updatedAt: 'asc' + }, + { + status: 'asc' + } + ] + }, + { + name: 'pooling', + unique: false, + properties: [ + { + status: 'asc' + }, + { + pooling: 'asc' + }, + { + coreFeePerByte: 'asc' + }, + { + $updatedAt: 'asc' + } + ] + }, + { + name: 'transaction', + unique: false, + properties: [ + { + status: 'asc' + }, + { + transactionIndex: 'asc' + } + ] + } + ], + required: [ + '$createdAt', + '$updatedAt', + 'amount', + 'coreFeePerByte', + 'pooling', + 'outputScript', + 'status' + ], + properties: { + amount: { + type: 'integer', + minimum: 1000, + position: 2, + description: 'The amount to be withdrawn' + }, + status: { + enum: [ + 0, + 1, + 2, + 3, + 4 + ], + type: 'integer', + position: 6, + description: '0 - Pending, 1 - Signed, 2 - Broadcasted, 3 - Complete, 4 - Expired' + }, + pooling: { + enum: [ + 0, + 1, + 2 + ], + type: 'integer', + position: 4, + description: 'This indicated the level at which Platform should try to pool this transaction' + }, + outputScript: { + type: 'array', + maxItems: 25, + minItems: 23, + position: 5, + byteArray: true + }, + coreFeePerByte: { + type: 'integer', + maximum: 4294967295, + minimum: 1, + position: 3, + description: 'This is the fee that you are willing to spend for this transaction in Duffs/Byte' + }, + transactionIndex: { + type: 'integer', + minimum: 1, + position: 0, + description: 'Sequential index of asset unlock (withdrawal) transaction. Populated when a withdrawal pooled into withdrawal transaction' + }, + transactionSignHeight: { + type: 'integer', + minimum: 1, + position: 1, + description: 'The Core height on which transaction was signed' + } + }, + description: 'Withdrawal document to track underlying withdrawal transactions. Withdrawals should be created with IdentityWithdrawalTransition', + additionalProperties: false, + creationRestrictionMode: 2 + } + } + mock.method(DAPI.prototype, 'getIdentityBalance', async () => 0) mock.method(DAPI.prototype, 'getContestedState', async () => null) @@ -97,6 +235,56 @@ describe('Identities routes', () => { }) }) + describe('getIdentityWithdrawalByIdentifier()', async () => { + it('should return default set of Withdrawals from state_transitions table', async () => { + block = await fixtures.block(knex) + const identity = await fixtures.identity(knex, { block_hash: block.hash }) + dataContract = await fixtures.dataContract(knex, { + owner: identity.identifier, + schema: dataContractSchema, + identifier: '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6' + }) + + transactions = [] + + for (let i = 0; i < 10; i++) { + block = await fixtures.block(knex) + + const transaction = await fixtures.transaction(knex, { + block_hash: block.hash, + type: StateTransitionEnum.IDENTITY_CREDIT_WITHDRAWAL, + owner: identity.owner + }) + + transactions.push({ transaction, block }) + } + + const withdrawals = transactions.sort((a, b) => a.block.height - b.block.height).map(transaction => ({ + timestamp: transaction.block.timestamp.toISOString(), + hash: null, + id: transaction.transaction.hash, + sender: transaction.transaction.owner, + amount: 12345678, + status: 3 + })) + + mock.method(DAPI.prototype, 'getDocuments', async () => withdrawals) + + const { body } = await client.get(`/identity/${identity.identifier}/withdrawals`) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8') + + assert.deepEqual(body, withdrawals.map(withdrawal => ({ ...withdrawal, hash: withdrawal.id }))) + }) + + it('should return 404 whe identity not exist', async () => { + mock.method(DAPI.prototype, 'getDocuments', async () => []) + await client.get('/identity/1234123123PFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6/withdrawals') + .expect(404) + .expect('Content-Type', 'application/json; charset=utf-8') + }) + }) + describe('getIdentityByDPNS()', async () => { it('should return identity by dpns', async () => { const block = await fixtures.block(knex) diff --git a/packages/frontend/src/app/api/content.md b/packages/frontend/src/app/api/content.md index 6aaf83d74..d39e894cd 100644 --- a/packages/frontend/src/app/api/content.md +++ b/packages/frontend/src/app/api/content.md @@ -677,7 +677,60 @@ Response codes: 200: OK 500: Internal Server Error ``` +--- +### Identity Withdrawals +Return all withdrawals for identity + +_Note: this request does not contain any pagination data in the response_ + +* `limit` cannot be more then 100 +``` +GET /identity/A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb/withdrawals?limit=5 +[ + { + "timestamp": 1729096625509, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "95eiiqMotMvH23f6cv3BPC4ykcHFWTy2g3baCTWZANAs", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729096140465, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "DJzb8nj7JTHwnvAGEGhyFc5hHLFa5Es9WFAyS4HhhNeF", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729096636318, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "E4gbWCQgqrz9DVrzCeDKhr4PVsfp6CeL5DUAYndRVWdk", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729096795042, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "FouX2qY8Eaxj5rSBrH9uxbhAM16ozrUP4sJwdo9pL7Cr", + "amount": 200000, + "status": 3 + }, + { + "timestamp": 1729097247874, + "sender": "A1rgGVjRGuznRThdAA316VEEpKuVQ7mV8mBK1BFJvXnb", + "id": "9VEpb2aJRnCxfi3LjFXWa1zshkBPfzzHHh5yqEkgqw1t", + "amount": 200000, + "status": 3 + } +] +``` +Response codes: +``` +200: OK +500: Internal Server Error +``` +--- ### Data contracts by Identity Return all data contracts by the given identity