Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(jellyfish-api-core): add wallet.listtransactions RPC #2060

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
17 changes: 17 additions & 0 deletions docs/node/CATEGORIES/05-wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,20 @@ interface wallet {
signMessage (address: string, message: string): Promise<string>
}
```

## listTransactions

List transactions based on the given criteria.

```ts title="client.wallet.listTransactions()"
interface wallet {
listTransactions ({
label?: string = '*',
count?: number = 10,
skip?: number = 0,
includeWatchOnly?: boolean = true
excludeCustomTx?: boolean = false
}): Promise<InWalletTransactionWithCategory[]>
}
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { BigNumber } from '@defichain/jellyfish-json'
import { MasterNodeRegTestContainer } from '@defichain/testcontainers'
import { ContainerAdapterClient } from '../../container_adapter_client'

describe('listTransactions', () => {
const container = new MasterNodeRegTestContainer()
const client = new ContainerAdapterClient(container)

const address = 'mwsZw8nF7pKxWH8eoKL9tPxTpaFkz7QeLU'

beforeAll(async () => {
await container.start()
await container.waitForWalletCoinbaseMaturity()
})

afterAll(async () => {
await container.stop()
})

it('should listTransactions', async () => {
await Promise.all(Array.from({ length: 10 }).map(async (_, i) => {
return await client.wallet.sendToAddress(address, 0.0001)
}))
await container.generate(1, address)

const inWalletTransactions = await client.wallet.listTransactions({})

expect(inWalletTransactions.length).toStrictEqual(10)

for (const inWalletTransaction of inWalletTransactions) {
expect(inWalletTransaction).toMatchObject({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we prefer toMatchObject here over toStrictEqual?

Copy link
Author

@andyrobert3 andyrobert3 Feb 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are the specifications on what attributes are returned by the listtransactions function on DeFiChain

https://github.com/DeFiCh/ain/blob/master/src/wallet/rpcwallet.cpp

RPCResult {
            "[\n"
            "  {\n"
            "    \"address\":\"address\",    (string) The defi address of the transaction.\n"
            "    \"category\":               (string) The transaction category.\n"
            "                \"send\"                  Transactions sent.\n"
            "                \"receive\"               Non-coinbase transactions received.\n"
            "                \"generate\"              Coinbase transactions received with more than 100 confirmations.\n"
            "                \"immature\"              Coinbase transactions received with 100 or fewer confirmations.\n"
            "                \"orphan\"                Orphaned coinbase transactions received.\n"
            "    \"amount\": x.xxx,          (numeric) The amount in " + CURRENCY_UNIT + ". This is negative for the 'send' category, and is positive\n"
            "                                        for all other categories\n"
            "    \"label\": \"label\",       (string) A comment for the address/transaction, if any\n"
            "    \"vout\": n,                (numeric) the vout value\n"
            "    \"fee\": x.xxx,             (numeric) The amount of the fee in " + CURRENCY_UNIT + ". This is negative and only available for the \n"
            "                                         'send' category of transactions.\n"
            "    \"confirmations\": n,       (numeric) The number of confirmations for the transaction. Negative confirmations indicate the\n"
            "                                         transaction conflicts with the block chain\n"
            "    \"trusted\": xxx,           (bool) Whether we consider the outputs of this unconfirmed transaction safe to spend.\n"
            "    \"blockhash\": \"hashvalue\", (string) The block hash containing the transaction.\n"
            "    \"blockindex\": n,          (numeric) The index of the transaction in the block that includes it.\n"
            "    \"blocktime\": xxx,         (numeric) The block time in seconds since epoch (1 Jan 1970 GMT).\n"
            "    \"txid\": \"transactionid\", (string) The transaction id.\n"
            "    \"time\": xxx,              (numeric) The transaction time in seconds since epoch (midnight Jan 1 1970 GMT).\n"
            "    \"timereceived\": xxx,      (numeric) The time received in seconds since epoch (midnight Jan 1 1970 GMT).\n"
            "    \"comment\": \"...\",       (string) If a comment is associated with the transaction.\n"
            "    \"bip125-replaceable\": \"yes|no|unknown\",  (string) Whether this transaction could be replaced due to BIP125 (replace-by-fee);\n"
            "                                                     may be unknown for unconfirmed transactions not in the mempool\n"
            "    \"abandoned\": xxx          (bool) 'true' if the transaction has been abandoned (inputs are respendable). Only available for the \n"
            "                                         'send' category of transactions.\n"
            "  }\n"
            "]\n"
}

When running the tests, sometimes there are additional optional returned parameters that are not included in the specifications above:

  • generated -> boolean
  • walletconflicts -> Array

These parameters appear in some responses, and not others.

Using toStrictEqual may fail tests here, the extra parameters generated & walletconflicts are not always included in the response.

However, toMatchObject is defined as "check that a JavaScript object matches a subset of the properties of an object"

In this case, we can just compare the response result if it matches a subset of the specification parameters from the DeFiChain repository

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anyone from the DeFi chain team knows when & why the 2 extra parameters appear, feel free to add on here so I can modify my tests to use toStrictEqual

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay that makes sense

I guess the RPC doc needs updating in ain then

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you know who should i ask here? @eli-lim

I'll probably raise a ticket to the ain repo?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, just raise a ticket

In the mean time, let's wait for others to review this PR

address: expect.any(String),
txid: expect.any(String),
amount: expect.any(BigNumber),
confirmations: expect.any(Number),
blockhash: expect.any(String),
blocktime: expect.any(Number),
blockindex: expect.any(Number),
time: expect.any(Number),
timereceived: expect.any(Number),
// "bip125-replaceable" is "BIP125" enum but we test with "string" here
'bip125-replaceable': expect.any(String),
// "category" is "InWalletTransactionCategory" enum but we test with "string" here
category: expect.any(String),
label: expect.any(String),
vout: expect.any(Number)
})
}
})

it('should listTransactions with label set', async () => {
await Promise.all(Array.from({ length: 10 }).map(async (_, i) => {
return await client.wallet.sendToAddress(address, 0.0001)
}))
await container.generate(1, address)

const inWalletTransactions = await client.wallet.listTransactions({ label: 'owner' })

inWalletTransactions.forEach((inWalletTransaction) => {
expect(inWalletTransaction.label).toStrictEqual('owner')
})

expect(inWalletTransactions.length).toStrictEqual(10)

for (const inWalletTransaction of inWalletTransactions) {
expect(inWalletTransaction).toMatchObject({
address: expect.any(String),
txid: expect.any(String),
amount: expect.any(BigNumber),
confirmations: expect.any(Number),
blockhash: expect.any(String),
blocktime: expect.any(Number),
blockindex: expect.any(Number),
time: expect.any(Number),
timereceived: expect.any(Number),
// "bip125-replaceable" is "BIP125" enum but we test with "string" here
'bip125-replaceable': expect.any(String),
// "category" is "InWalletTransactionCategory" enum but we test with "string" here
category: expect.any(String),
label: expect.any(String),
vout: expect.any(Number)
})
}
})

it('should listTransactions with label set', async () => {
await Promise.all(Array.from({ length: 10 }).map(async (_, i) => {
return await client.wallet.sendToAddress(address, 0.0001)
}))
await container.generate(1, address)

const inWalletTransactions = await client.wallet.listTransactions({ label: 'owner' })

inWalletTransactions.forEach((inWalletTransaction) => {
expect(inWalletTransaction.label).toStrictEqual('owner')
})

expect(inWalletTransactions.length).toStrictEqual(10)

for (const inWalletTransaction of inWalletTransactions) {
expect(inWalletTransaction).toMatchObject({
address: expect.any(String),
txid: expect.any(String),
amount: expect.any(BigNumber),
confirmations: expect.any(Number),
blockhash: expect.any(String),
blocktime: expect.any(Number),
blockindex: expect.any(Number),
time: expect.any(Number),
timereceived: expect.any(Number),
// "bip125-replaceable" is "BIP125" enum but we test with "string" here
'bip125-replaceable': expect.any(String),
// "category" is "InWalletTransactionCategory" enum but we test with "string" here
category: expect.any(String),
label: expect.any(String),
vout: expect.any(Number)
})
}
})

it('should listTransactions with count = 5', async () => {
await Promise.all(Array.from({ length: 5 }).map(async (_, i) => {
return await client.wallet.sendToAddress(address, 0.0001)
}))
await container.generate(1, address)

const inWalletTransactions = await client.wallet.listTransactions({ count: 5 })

expect(inWalletTransactions.length).toStrictEqual(5)

for (const inWalletTransaction of inWalletTransactions) {
expect(inWalletTransaction).toMatchObject({
address: expect.any(String),
txid: expect.any(String),
amount: expect.any(BigNumber),
confirmations: expect.any(Number),
blockhash: expect.any(String),
blocktime: expect.any(Number),
blockindex: expect.any(Number),
time: expect.any(Number),
timereceived: expect.any(Number),
// "bip125-replaceable" is "BIP125" enum but we test with "string" here
'bip125-replaceable': expect.any(String),
// "category" is "InWalletTransactionCategory" enum but we test with "string" here
category: expect.any(String),
label: expect.any(String),
vout: expect.any(Number)
})
}
})

it('should not listTransactions with count = -1', async () => {
await expect(client.wallet.listTransactions({ count: -1 })).rejects.toThrow('RpcApiError: \'Negative count\', code: -8, method: listtransactions')
})

it('should listTransactions with count = 0', async () => {
const inWalletTransactions = await client.wallet.listTransactions({ count: 0 })
expect(inWalletTransactions.length).toStrictEqual(0)
})

it('should listTransactions with includeWatchOnly false', async () => {
await Promise.all(Array.from({ length: 10 }).map(async (_, i) => {
return await client.wallet.sendToAddress(address, 0.0001)
}))
await container.generate(1, address)

const inWalletTransactions = await client.wallet.listTransactions({ includeWatchOnly: false })

inWalletTransactions.forEach((inWalletTransaction) => {
expect(inWalletTransaction.address).toStrictEqual(address)
})

expect(inWalletTransactions.length).toStrictEqual(10)

for (const inWalletTransaction of inWalletTransactions) {
expect(inWalletTransaction).toMatchObject({
address: expect.any(String),
txid: expect.any(String),
amount: expect.any(BigNumber),
confirmations: expect.any(Number),
blockhash: expect.any(String),
blocktime: expect.any(Number),
blockindex: expect.any(Number),
time: expect.any(Number),
timereceived: expect.any(Number),
// "bip125-replaceable" is "BIP125" enum but we test with "string" here
'bip125-replaceable': expect.any(String),
// "category" is "InWalletTransactionCategory" enum but we test with "string" here
category: expect.any(String),
label: expect.any(String),
vout: expect.any(Number)
})
}
})

it('should listTransactions with excludeCustomTx = true', async () => {
await Promise.all(Array.from({ length: 10 }).map(async (_, i) => {
return await client.wallet.sendToAddress(address, 0.0001)
}))
await container.generate(1, address)

const inWalletTransactions = await client.wallet.listTransactions({ excludeCustomTx: true })

inWalletTransactions.forEach((inWalletTransaction) => {
expect(inWalletTransaction.address).toStrictEqual(address)
})

expect(inWalletTransactions.length).toStrictEqual(10)

for (const inWalletTransaction of inWalletTransactions) {
expect(inWalletTransaction).toMatchObject({
address: expect.any(String),
txid: expect.any(String),
amount: expect.any(BigNumber),
confirmations: expect.any(Number),
blockhash: expect.any(String),
blocktime: expect.any(Number),
blockindex: expect.any(Number),
time: expect.any(Number),
timereceived: expect.any(Number),
// "bip125-replaceable" is "BIP125" enum but we test with "string" here
'bip125-replaceable': expect.any(String),
// "category" is "InWalletTransactionCategory" enum but we test with "string" here
category: expect.any(String),
label: expect.any(String),
vout: expect.any(Number)
})
}
})
})
47 changes: 47 additions & 0 deletions packages/jellyfish-api-core/src/category/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,33 @@ export class Wallet {
return await this.client.call('listwallets', [], 'number')
}

/**
* If a label name is provided, this will return only incoming transactions paying to addresses with the specified label.
* Returns up to 'count' most recent transactions skipping the first 'from' transactions.
*
* @param {string} label If set, should be a valid label name to return only incoming transactions with the specified label, or '*' to disable filtering and return all transactions. (default = '*')
* @param {string} count The number of transactions to return (default = 10)
* @param {string} skip The number of transactions to skip (default = 0)
* @param {boolean} includeWatchOnly Whether to include watch-only addresses (default = true)
* @param {boolean} excludeCustomTx False to include all transactions, otherwise exclude custom transactions (default = false)
* @return {Promise<Array<InWalletTransactionWithFlatDetails>>}
*/
async listTransactions ({
label = '*',
count = 10,
skip = 0,
includeWatchOnly = true,
excludeCustomTx = false
}: {
label?: string
count?: number
skip?: number
includeWatchOnly?: boolean
excludeCustomTx?: boolean
}): Promise<InWalletTransactionWithFlatDetails[]> {
return await this.client.call('listtransactions', [label, count, skip, includeWatchOnly, excludeCustomTx], { amount: 'bignumber' })
}

/**
* Sign a message with the private key of an address
* Requires wallet to be unlocked for usage. Use `walletpassphrase` to unlock wallet.
Expand Down Expand Up @@ -494,6 +521,26 @@ export interface InWalletTransaction {
hex: string
}

export interface InWalletTransactionWithFlatDetails {
address: string
category: InWalletTransactionCategory
amount: BigNumber
label?: string
vout: number
fee?: number
confirmations: number
trusted: boolean
blockhash: string
blockindex: number
blocktime: number
txid: string
time: number
timereceived: number
comment?: string
'bip125-replaceable': BIP125
abandoned?: boolean
}

export interface InWalletTransactionDetail {
address: string
category: InWalletTransactionCategory
Expand Down