From ed5f7478a10a8f88c8fe074b1f9eef76065cb423 Mon Sep 17 00:00:00 2001 From: Austin Woetzel <30289932+AustinWoetzel@users.noreply.github.com> Date: Tue, 4 Jun 2024 16:04:45 -0500 Subject: [PATCH 1/2] feat: snip20 transaction history query (#152) * feat: snip20 transaction history query * docs: formatting issue * fix: transaction history query msg * feat: remove uneeded property from the model --- .changeset/beige-forks-develop.md | 5 + docs/queries/snip20.md | 56 ++++++- src/contracts/definitions/snip20.test.ts | 64 +++++++- src/contracts/definitions/snip20.ts | 45 ++++++ src/contracts/services/snip20.test.ts | 92 +++++++++++ src/contracts/services/snip20.ts | 143 ++++++++++++++++++ .../batchQueryTransactionHistoryParsed.ts | 39 +++++ .../batchQueryTransactionHistoryUnparsed.ts | 50 ++++++ ...ctionHistoryWithViewingKeyErrorUnparsed.ts | 17 +++ .../transferHistoryEmptyPageResponse.json | 6 + .../rawResponses/transferHistoryResponse.json | 38 +++++ .../rawResponses/viewingKeyErrorResponse.json | 5 + src/types/contracts/snip20/index.ts | 1 + .../snip20/transactionHistory/index.ts | 2 + .../snip20/transactionHistory/model.ts | 22 +++ .../snip20/transactionHistory/response.ts | 13 ++ 16 files changed, 590 insertions(+), 8 deletions(-) create mode 100644 .changeset/beige-forks-develop.md create mode 100644 src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryParsed.ts create mode 100644 src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryUnparsed.ts create mode 100644 src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts create mode 100644 src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryEmptyPageResponse.json create mode 100644 src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryResponse.json create mode 100644 src/test/mocks/snip20/transactionHistory/rawResponses/viewingKeyErrorResponse.json create mode 100644 src/types/contracts/snip20/transactionHistory/index.ts create mode 100644 src/types/contracts/snip20/transactionHistory/model.ts create mode 100644 src/types/contracts/snip20/transactionHistory/response.ts diff --git a/.changeset/beige-forks-develop.md b/.changeset/beige-forks-develop.md new file mode 100644 index 0000000..c633c53 --- /dev/null +++ b/.changeset/beige-forks-develop.md @@ -0,0 +1,5 @@ +--- +"@shadeprotocol/shadejs": patch +--- + +snip20 transaction history using a batch query diff --git a/docs/queries/snip20.md b/docs/queries/snip20.md index e85d046..c48eaa5 100644 --- a/docs/queries/snip20.md +++ b/docs/queries/snip20.md @@ -176,4 +176,58 @@ console.log(output) ```md '123' -``` \ No newline at end of file +``` + +## Get Transaction History + +**input** + +```ts +async function querySnip20TransactionHistory({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + shouldFilterDecoys, + minBlockHeightValidationOptions, +}:{ + queryRouterContractAddress: string, + queryRouterCodeHash?: string, + lcdEndpoint?: string, + chainId?: string, + snip20ContractAddress: string, + snip20CodeHash: string, + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + shouldFilterDecoys?: boolean, + minBlockHeightValidationOptions?: MinBlockHeightValidationOptions, +}): Promise +``` + +**output** + +```ts +type Snip20Tx = { + id: number; + action: TxAction; // see secretJS types + denom: string, + amount: string, + memo?: string; + blockTime?: number; + blockHeight?: number; +} + +type TransactionHistory = { + txs: Snip20Tx[], + tokenAddress: string, + totalTransactions?: number, + blockHeight: number, +} diff --git a/src/contracts/definitions/snip20.test.ts b/src/contracts/definitions/snip20.test.ts index b9daae9..3f22235 100644 --- a/src/contracts/definitions/snip20.test.ts +++ b/src/contracts/definitions/snip20.test.ts @@ -1,6 +1,63 @@ import { test, expect } from 'vitest'; import { snip20 } from '~/contracts/definitions/snip20'; +// QUERIES +test('it checks the shape of the snip20 balance query', () => { + const output = { + balance: { + address: 'MOCK_ADDRESS', + key: 'MOCK_KEY', + }, + }; + expect(snip20.queries.getBalance('MOCK_ADDRESS', 'MOCK_KEY')).toStrictEqual(output); +}); + +test('it checks the shape of the snip20 token info query', () => { + const output = { + token_info: {}, + }; + expect(snip20.queries.tokenInfo()).toStrictEqual(output); +}); + +test('it checks the shape of the snip20 transaction history query', () => { + const input = { + ownerAddress: 'MOCK_OWNER_ADDRESS', + viewingKey: 'MOCK_VIEWING_KEY', + page: 1, + pageSize: 2, + shouldFilterDecoys: true, + }; + const output = { + transaction_history: { + address: input.ownerAddress, + page_size: input.pageSize, + page: input.page, + key: input.viewingKey, + should_filter_decoys: input.shouldFilterDecoys, + }, + }; + expect(snip20.queries.getTransactionHistory(input)).toStrictEqual(output); +}); + +test('it checks the shape of the snip20 transfer history query', () => { + const input = { + ownerAddress: 'MOCK_OWNER_ADDRESS', + viewingKey: 'MOCK_VIEWING_KEY', + page: 1, + pageSize: 2, + }; + const output = { + transfer_history: { + address: input.ownerAddress, + page_size: input.pageSize, + page: input.page, + key: input.viewingKey, + }, + }; + expect(snip20.queries.getTransferHistory(input)).toStrictEqual(output); +}); + +// EXECUTIONS test('it checks the shape of the snip20 send', () => { const params = { recipient: 'MOCK_RECIPIENT', @@ -103,13 +160,6 @@ test('it checks the shape of the snip20 redeem', () => { })).toStrictEqual(output); }); -test('it checks the shape of the snip20 token info query', () => { - const output = { - token_info: {}, - }; - expect(snip20.queries.tokenInfo()).toStrictEqual(output); -}); - test('it checks the shape of the snip20 increase allowance', () => { const inputSpender = 'MOCK_SPENDER'; const inputAmount = 'MOCK_AMOUNT'; diff --git a/src/contracts/definitions/snip20.ts b/src/contracts/definitions/snip20.ts index a491b3f..a754585 100644 --- a/src/contracts/definitions/snip20.ts +++ b/src/contracts/definitions/snip20.ts @@ -16,6 +16,51 @@ const snip20 = { token_info: {}, }; }, + getTransactionHistory({ + ownerAddress, + viewingKey, + page, + pageSize, + shouldFilterDecoys, + }:{ + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + shouldFilterDecoys: boolean, + }) { + return { + transaction_history: { + address: ownerAddress, + page_size: pageSize, + page, + key: viewingKey, + should_filter_decoys: shouldFilterDecoys, + }, + }; + }, + // transfer history is a query message used for older snip20s that do + // not support the newer transaction_history query + getTransferHistory({ + ownerAddress, + viewingKey, + page, + pageSize, + }:{ + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + }) { + return { + transfer_history: { + address: ownerAddress, + page_size: pageSize, + page, + key: viewingKey, + }, + }; + }, }, messages: { send({ diff --git a/src/contracts/services/snip20.test.ts b/src/contracts/services/snip20.test.ts index 0fe0804..2c1ca56 100644 --- a/src/contracts/services/snip20.test.ts +++ b/src/contracts/services/snip20.test.ts @@ -8,6 +8,9 @@ import { parseBatchQueryTokensInfo, batchQuerySnip20TokensInfo, batchQuerySnip20TokensInfo$, + querySnip20TransactionHistory$, + querySnip20TransactionHistory, + parseSnip20TransactionHistoryResponse, } from '~/contracts/services/snip20'; import { test, @@ -19,8 +22,11 @@ import tokenInfoResponse from '~/test/mocks/snip20/tokenInfoResponse.json'; import { tokenInfoParsed } from '~/test/mocks/snip20/tokenInfoParsed'; import { batchTokensInfoParsed } from '~/test/mocks/snip20/batchQueryTokensInfoParsed'; import { batchTokensInfoUnparsed } from '~/test/mocks/snip20/batchQueryTokensInfoUnparsed'; +import { batchSnip20TransactionHistoryUnparsed } from '~/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryUnparsed'; import { of } from 'rxjs'; import balanceResponse from '~/test/mocks/snip20/balanceResponse.json'; +import { transactionHistoryParsed } from '~/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryParsed'; +import { batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed } from '~/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed'; const sendSecretClientContractQuery$ = vi.hoisted(() => vi.fn()); const batchQuery$ = vi.hoisted(() => vi.fn()); @@ -31,6 +37,7 @@ beforeAll(() => { queries: { tokenInfo: vi.fn(() => 'TOKEN_INFO_MSG'), getBalance: vi.fn(() => 'GET_BALANCE_MSG'), + getTransactionHistory: vi.fn(() => 'GET_TRANSACTION_HISTORY_MSG'), }, }, })); @@ -66,6 +73,12 @@ test('it can parse the batch snip20 token info query', () => { )).toStrictEqual(batchTokensInfoParsed); }); +test('it can parse the batch snip20 token info query', () => { + expect(parseSnip20TransactionHistoryResponse( + batchSnip20TransactionHistoryUnparsed, + )).toStrictEqual(transactionHistoryParsed); +}); + test('it can call the snip20 token info query', async () => { const input = { snip20ContractAddress: 'CONTRACT_ADDRESS', @@ -205,3 +218,82 @@ test('it can call the snip20 balance query', async () => { expect(response).toStrictEqual('123'); }); + +test('it can call the snip20 transaction history query', async () => { + const input = { + queryRouterContractAddress: 'CONTRACT_ADDRESS', + queryRouterCodeHash: 'CODE_HASH', + lcdEndpoint: 'LCD_ENDPOINT', + chainId: 'CHAIN_ID', + snip20ContractAddress: 'MOCK_ADDRESS', + snip20CodeHash: 'MOCK_CODE_HASH', + ownerAddress: 'OWNER_ADDRESS', + viewingKey: 'VIEWING_KEY', + page: 1, + pageSize: 1, + }; + + // observables function + batchQuery$.mockReturnValueOnce(of(batchSnip20TransactionHistoryUnparsed)); + let output; + querySnip20TransactionHistory$(input).subscribe({ + next: (response) => { + output = response; + }, + }); + + expect(batchQuery$).toHaveBeenCalledWith({ + contractAddress: input.queryRouterContractAddress, + codeHash: input.queryRouterCodeHash, + lcdEndpoint: input.lcdEndpoint, + chainId: input.chainId, + queries: [{ + id: input.snip20ContractAddress, + contract: { + address: input.snip20ContractAddress, + codeHash: input.snip20CodeHash, + }, + queryMsg: 'GET_TRANSACTION_HISTORY_MSG', + }], + }); + + expect(output).toStrictEqual(transactionHistoryParsed); + + // async/await function + batchQuery$.mockReturnValueOnce(of(batchSnip20TransactionHistoryUnparsed)); + const response = await querySnip20TransactionHistory(input); + expect(batchQuery$).toHaveBeenCalledWith({ + contractAddress: input.queryRouterContractAddress, + codeHash: input.queryRouterCodeHash, + lcdEndpoint: input.lcdEndpoint, + chainId: input.chainId, + queries: [{ + id: input.snip20ContractAddress, + contract: { + address: input.snip20ContractAddress, + codeHash: input.snip20CodeHash, + }, + queryMsg: 'GET_TRANSACTION_HISTORY_MSG', + }], + }); + expect(response).toStrictEqual(transactionHistoryParsed); +}); + +test('it can handle the viewing key error on the snip20 transaction history query', async () => { + const input = { + queryRouterContractAddress: 'CONTRACT_ADDRESS', + queryRouterCodeHash: 'CODE_HASH', + lcdEndpoint: 'LCD_ENDPOINT', + chainId: 'CHAIN_ID', + snip20ContractAddress: 'MOCK_ADDRESS', + snip20CodeHash: 'MOCK_CODE_HASH', + ownerAddress: 'OWNER_ADDRESS', + viewingKey: 'VIEWING_KEY', + page: 1, + pageSize: 1, + }; + + batchQuery$.mockReturnValueOnce(of(batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed)); + + await expect(() => querySnip20TransactionHistory(input)).rejects.toThrowError('Wrong viewing key for this address or viewing key not set'); +}); diff --git a/src/contracts/services/snip20.ts b/src/contracts/services/snip20.ts index e7bd816..25906da 100644 --- a/src/contracts/services/snip20.ts +++ b/src/contracts/services/snip20.ts @@ -17,6 +17,9 @@ import { BatchQueryParams, BatchQueryParsedResponse, MinBlockHeightValidationOptions, + TransactionHistory, + Snip20TransactionHistoryResponse, + Snip20Tx, } from '~/types'; import { batchQuery$ } from './batchQuery'; @@ -210,6 +213,143 @@ async function querySnip20Balance({ })); } +/** + * parses the snip20 transaction history response + */ +const parseSnip20TransactionHistoryResponse = ( + response: BatchQueryParsedResponse, +): TransactionHistory => { + // validate that a single response is available, should only be true if parser is used incorrectly + if (response.length !== 1) { + throw new Error('Only one response is expected for the snip20 transaction history query'); + } + const transactionHistoryResponse = response[0].response as Snip20TransactionHistoryResponse; + + if ('viewing_key_error' in transactionHistoryResponse) { + throw new Error(transactionHistoryResponse.viewing_key_error.msg); + } + + const parsedTxs: Snip20Tx[] = transactionHistoryResponse.transaction_history.txs.map((tx) => ({ + id: tx.id, + action: tx.action, + denom: tx.coins.denom, + amount: tx.coins.amount, + memo: tx.memo, + blockTime: tx.block_time, + blockHeight: tx.block_height, + })); + + return { + txs: parsedTxs, + totalTransactions: transactionHistoryResponse.transaction_history.total, + blockHeight: response[0].blockHeight, + }; +}; + +/** + * query the snip20 transaction history + */ +function querySnip20TransactionHistory$({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + shouldFilterDecoys = true, + minBlockHeightValidationOptions, +}:{ + queryRouterContractAddress: string, + queryRouterCodeHash?: string, + lcdEndpoint?: string, + chainId?: string, + snip20ContractAddress: string, + snip20CodeHash: string, + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + shouldFilterDecoys?: boolean, + minBlockHeightValidationOptions?: MinBlockHeightValidationOptions, +}) { + const query:BatchQueryParams = { + id: snip20ContractAddress, + contract: { + address: snip20ContractAddress, + codeHash: snip20CodeHash, + }, + queryMsg: snip20.queries.getTransactionHistory({ + ownerAddress, + viewingKey, + page, + pageSize, + shouldFilterDecoys, + }), + }; + + return batchQuery$({ + contractAddress: queryRouterContractAddress, + codeHash: queryRouterCodeHash, + lcdEndpoint, + chainId, + queries: [query], + minBlockHeightValidationOptions, + }).pipe( + map(parseSnip20TransactionHistoryResponse), + first(), + ); +} + +/** + * query the snip20 transaction history + */ +async function querySnip20TransactionHistory({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + shouldFilterDecoys, + minBlockHeightValidationOptions, +}:{ + queryRouterContractAddress: string, + queryRouterCodeHash?: string, + lcdEndpoint?: string, + chainId?: string, + snip20ContractAddress: string, + snip20CodeHash: string, + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + shouldFilterDecoys?: boolean, + minBlockHeightValidationOptions?: MinBlockHeightValidationOptions, +}) { + return lastValueFrom(querySnip20TransactionHistory$({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + shouldFilterDecoys, + minBlockHeightValidationOptions, + })); +} + export { querySnip20TokenInfo$, parseTokenInfo, @@ -220,4 +360,7 @@ export { parseBatchQueryTokensInfo, batchQuerySnip20TokensInfo$, batchQuerySnip20TokensInfo, + querySnip20TransactionHistory$, + querySnip20TransactionHistory, + parseSnip20TransactionHistoryResponse, }; diff --git a/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryParsed.ts b/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryParsed.ts new file mode 100644 index 0000000..c727fc9 --- /dev/null +++ b/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryParsed.ts @@ -0,0 +1,39 @@ +import { TransactionHistory } from '~/types'; + +const transactionHistoryParsed: TransactionHistory = { + txs: [{ + id: 123456, + action: { + mint: { + minter: 'MINTER_ADDRESS_1', + recipient: 'RECIPIENT_ADDRESS_1', + }, + }, + denom: 'SHD', + amount: '100000000', + memo: undefined, + blockTime: 1717603001, + blockHeight: 14537355, + }, + { + id: 789456, + action: { + transfer: { + from: 'FROM_ADDRESS', + sender: 'SENDER_ADDRESS', + recipient: 'RECIPIENT_ADDRESS', + }, + }, + denom: 'SHD', + amount: '70', + memo: undefined, + blockTime: 1717689401, + blockHeight: 14537363, + }], + totalTransactions: 99, + blockHeight: 3, +}; + +export { + transactionHistoryParsed, +}; diff --git a/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryUnparsed.ts b/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryUnparsed.ts new file mode 100644 index 0000000..0a046b6 --- /dev/null +++ b/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryUnparsed.ts @@ -0,0 +1,50 @@ +import { BatchQueryParsedResponse } from '~/types'; + +const batchSnip20TransactionHistoryUnparsed:BatchQueryParsedResponse = [ + { + id: 'secret153wu605vvp934xhd4k9dtd640zsep5jkesstdm', + response: { + transaction_history: { + txs: [ + { + id: 123456, + action: { + mint: { + minter: 'MINTER_ADDRESS_1', + recipient: 'RECIPIENT_ADDRESS_1', + }, + }, + coins: { + denom: 'SHD', + amount: '100000000', + }, + block_time: 1717603001, + block_height: 14537355, + }, + { + id: 789456, + action: { + transfer: { + from: 'FROM_ADDRESS', + sender: 'SENDER_ADDRESS', + recipient: 'RECIPIENT_ADDRESS', + }, + }, + coins: { + denom: 'SHD', + amount: '70', + }, + block_time: 1717689401, + block_height: 14537363, + }, + ], + total: 99, + }, + }, + blockHeight: 3, + }, +]; + +export { + batchSnip20TransactionHistoryUnparsed, +}; diff --git a/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts b/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts new file mode 100644 index 0000000..20cf733 --- /dev/null +++ b/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts @@ -0,0 +1,17 @@ +import { BatchQueryParsedResponse } from '~/types'; + +const batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed:BatchQueryParsedResponse = [ + { + id: 'secret153wu605vvp934xhd4k9dtd640zsep5jkesstdm', + response: { + viewing_key_error: { + msg: 'Wrong viewing key for this address or viewing key not set', + }, + }, + blockHeight: 3, + }, +]; + +export { + batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed, +}; diff --git a/src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryEmptyPageResponse.json b/src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryEmptyPageResponse.json new file mode 100644 index 0000000..7351297 --- /dev/null +++ b/src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryEmptyPageResponse.json @@ -0,0 +1,6 @@ +{ + "transaction_history": { + "txs": [], + "total": 99 + } +} diff --git a/src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryResponse.json b/src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryResponse.json new file mode 100644 index 0000000..ecba2ae --- /dev/null +++ b/src/test/mocks/snip20/transactionHistory/rawResponses/transferHistoryResponse.json @@ -0,0 +1,38 @@ +{ + "transaction_history": { + "txs": [ + { + "id": 123456, + "action": { + "mint": { + "minter": "MINTER_ADDRESS_1", + "recipient": "RECIPIENT_ADDRESS_1" + } + }, + "coins": { + "denom": "SHD", + "amount": "100000000" + }, + "block_time": 1717603001, + "block_height": 14537355 + }, + { + "id": 789456, + "action": { + "transfer": { + "from": "FROM_ADDRESS", + "sender": "SENDER_ADDRESS", + "recipient": "RECIPIENT_ADDRESS" + } + }, + "coins": { + "denom": "SHD", + "amount": "70" + }, + "block_time": 1717689401, + "block_height": 14537363 + } + ], + "total": 99 + } +} diff --git a/src/test/mocks/snip20/transactionHistory/rawResponses/viewingKeyErrorResponse.json b/src/test/mocks/snip20/transactionHistory/rawResponses/viewingKeyErrorResponse.json new file mode 100644 index 0000000..efeb3df --- /dev/null +++ b/src/test/mocks/snip20/transactionHistory/rawResponses/viewingKeyErrorResponse.json @@ -0,0 +1,5 @@ +{ + "viewing_key_error": { + "msg": "Wrong viewing key for this address or viewing key not set" + } +} \ No newline at end of file diff --git a/src/types/contracts/snip20/index.ts b/src/types/contracts/snip20/index.ts index ed9aa71..77db27d 100644 --- a/src/types/contracts/snip20/index.ts +++ b/src/types/contracts/snip20/index.ts @@ -1,2 +1,3 @@ export * from './model'; export * from './response'; +export * from './transactionHistory'; diff --git a/src/types/contracts/snip20/transactionHistory/index.ts b/src/types/contracts/snip20/transactionHistory/index.ts new file mode 100644 index 0000000..ed9aa71 --- /dev/null +++ b/src/types/contracts/snip20/transactionHistory/index.ts @@ -0,0 +1,2 @@ +export * from './model'; +export * from './response'; diff --git a/src/types/contracts/snip20/transactionHistory/model.ts b/src/types/contracts/snip20/transactionHistory/model.ts new file mode 100644 index 0000000..acbb388 --- /dev/null +++ b/src/types/contracts/snip20/transactionHistory/model.ts @@ -0,0 +1,22 @@ +import { TxAction } from 'secretjs/dist/extensions/snip20/txTypes'; + +type Snip20Tx = { + id: number; + action: TxAction; + denom: string, + amount: string, + memo?: string; + blockTime?: number; + blockHeight?: number; +} + +type TransactionHistory = { + txs: Snip20Tx[], + totalTransactions?: number, + blockHeight: number, +} + +export type { + Snip20Tx, + TransactionHistory, +}; diff --git a/src/types/contracts/snip20/transactionHistory/response.ts b/src/types/contracts/snip20/transactionHistory/response.ts new file mode 100644 index 0000000..22c4780 --- /dev/null +++ b/src/types/contracts/snip20/transactionHistory/response.ts @@ -0,0 +1,13 @@ +import { TransactionHistoryResponse as TransactionHistoryResponseSecretJS } from 'secretjs/dist/extensions/snip20/types'; + +type ViewingKeyErrorResponse = { + viewing_key_error: { + msg: string + } +} + +type Snip20TransactionHistoryResponse = TransactionHistoryResponseSecretJS | ViewingKeyErrorResponse + +export type { + Snip20TransactionHistoryResponse, +}; From 1cc4633b815f59fd738b3776d6d9fb79f45f902a Mon Sep 17 00:00:00 2001 From: Austin Woetzel <30289932+AustinWoetzel@users.noreply.github.com> Date: Wed, 5 Jun 2024 11:04:00 -0500 Subject: [PATCH 2/2] Snip20 transfer history query (#153) * feat: snip20 transfer history query * chore: update transfer ids in mock * docs: comments and docs site update * chore: add changeset * docs: fix typo * feat: improved error handling on transaction history queries --- .changeset/red-seahorses-cough.md | 5 + docs/queries/snip20.md | 40 ++++ src/contracts/services/snip20.test.ts | 95 ++++++++- src/contracts/services/snip20.ts | 181 +++++++++++++++++- ...ctionHistoryWithViewingKeyErrorUnparsed.ts | 0 .../batchQueryTransferHistoryParsed.ts | 40 ++++ .../batchQueryTransferHistoryUnparsed.ts | 38 ++++ .../transferHistoryResponse.json | 26 +++ .../viewingKeyErrorResponse.json | 0 .../snip20/transactionHistory/response.ts | 8 +- 10 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 .changeset/red-seahorses-cough.md rename src/test/mocks/snip20/{transactionHistory => }/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts (100%) create mode 100644 src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryParsed.ts create mode 100644 src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryUnparsed.ts create mode 100644 src/test/mocks/snip20/transferHistory/transferHistoryResponse.json rename src/test/mocks/snip20/{transactionHistory/rawResponses => }/viewingKeyErrorResponse.json (100%) diff --git a/.changeset/red-seahorses-cough.md b/.changeset/red-seahorses-cough.md new file mode 100644 index 0000000..9494109 --- /dev/null +++ b/.changeset/red-seahorses-cough.md @@ -0,0 +1,5 @@ +--- +"@shadeprotocol/shadejs": patch +--- + +snip20 transfer history query diff --git a/docs/queries/snip20.md b/docs/queries/snip20.md index c48eaa5..9180417 100644 --- a/docs/queries/snip20.md +++ b/docs/queries/snip20.md @@ -231,3 +231,43 @@ type TransactionHistory = { totalTransactions?: number, blockHeight: number, } +``` + +## Get Transfer History +This query is used for legacy snip20s that do not support the newer transaction history query. + +**input** + +```ts +async function querySnip20TransferHistory({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + minBlockHeightValidationOptions, +}:{ + queryRouterContractAddress: string, + queryRouterCodeHash?: string, + lcdEndpoint?: string, + chainId?: string, + snip20ContractAddress: string, + snip20CodeHash: string, + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + minBlockHeightValidationOptions?: MinBlockHeightValidationOptions, +}): Promise +``` + +**output** + +```ts +see querySnip20TransactionHistory for output type +``` \ No newline at end of file diff --git a/src/contracts/services/snip20.test.ts b/src/contracts/services/snip20.test.ts index 2c1ca56..7b5e0d2 100644 --- a/src/contracts/services/snip20.test.ts +++ b/src/contracts/services/snip20.test.ts @@ -11,6 +11,9 @@ import { querySnip20TransactionHistory$, querySnip20TransactionHistory, parseSnip20TransactionHistoryResponse, + querySnip20TransferHistory$, + querySnip20TransferHistory, + parseSnip20TransferHistoryResponse, } from '~/contracts/services/snip20'; import { test, @@ -26,7 +29,9 @@ import { batchSnip20TransactionHistoryUnparsed } from '~/test/mocks/snip20/trans import { of } from 'rxjs'; import balanceResponse from '~/test/mocks/snip20/balanceResponse.json'; import { transactionHistoryParsed } from '~/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryParsed'; -import { batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed } from '~/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed'; +import { batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed } from '~/test/mocks/snip20/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed'; +import { batchSnip20TransferHistoryUnparsed } from '~/test/mocks/snip20/transferHistory/batchQueryTransferHistoryUnparsed'; +import { transferHistoryParsed } from '~/test/mocks/snip20/transferHistory/batchQueryTransferHistoryParsed'; const sendSecretClientContractQuery$ = vi.hoisted(() => vi.fn()); const batchQuery$ = vi.hoisted(() => vi.fn()); @@ -38,6 +43,7 @@ beforeAll(() => { tokenInfo: vi.fn(() => 'TOKEN_INFO_MSG'), getBalance: vi.fn(() => 'GET_BALANCE_MSG'), getTransactionHistory: vi.fn(() => 'GET_TRANSACTION_HISTORY_MSG'), + getTransferHistory: vi.fn(() => 'GET_TRANSFER_HISTORY_MSG'), }, }, })); @@ -73,12 +79,18 @@ test('it can parse the batch snip20 token info query', () => { )).toStrictEqual(batchTokensInfoParsed); }); -test('it can parse the batch snip20 token info query', () => { +test('it can parse the snip20 transaction history query', () => { expect(parseSnip20TransactionHistoryResponse( batchSnip20TransactionHistoryUnparsed, )).toStrictEqual(transactionHistoryParsed); }); +test('it can parse the snip20 transfer history query', () => { + expect(parseSnip20TransferHistoryResponse( + batchSnip20TransferHistoryUnparsed, + )).toStrictEqual(transferHistoryParsed); +}); + test('it can call the snip20 token info query', async () => { const input = { snip20ContractAddress: 'CONTRACT_ADDRESS', @@ -297,3 +309,82 @@ test('it can handle the viewing key error on the snip20 transaction history quer await expect(() => querySnip20TransactionHistory(input)).rejects.toThrowError('Wrong viewing key for this address or viewing key not set'); }); + +test('it can call the snip20 transfer history query', async () => { + const input = { + queryRouterContractAddress: 'CONTRACT_ADDRESS', + queryRouterCodeHash: 'CODE_HASH', + lcdEndpoint: 'LCD_ENDPOINT', + chainId: 'CHAIN_ID', + snip20ContractAddress: 'MOCK_ADDRESS', + snip20CodeHash: 'MOCK_CODE_HASH', + ownerAddress: 'OWNER_ADDRESS', + viewingKey: 'VIEWING_KEY', + page: 1, + pageSize: 1, + }; + + // observables function + batchQuery$.mockReturnValueOnce(of(batchSnip20TransferHistoryUnparsed)); + let output; + querySnip20TransferHistory$(input).subscribe({ + next: (response) => { + output = response; + }, + }); + + expect(batchQuery$).toHaveBeenCalledWith({ + contractAddress: input.queryRouterContractAddress, + codeHash: input.queryRouterCodeHash, + lcdEndpoint: input.lcdEndpoint, + chainId: input.chainId, + queries: [{ + id: input.snip20ContractAddress, + contract: { + address: input.snip20ContractAddress, + codeHash: input.snip20CodeHash, + }, + queryMsg: 'GET_TRANSFER_HISTORY_MSG', + }], + }); + + expect(output).toStrictEqual(transferHistoryParsed); + + // async/await function + batchQuery$.mockReturnValueOnce(of(batchSnip20TransferHistoryUnparsed)); + const response = await querySnip20TransferHistory(input); + expect(batchQuery$).toHaveBeenCalledWith({ + contractAddress: input.queryRouterContractAddress, + codeHash: input.queryRouterCodeHash, + lcdEndpoint: input.lcdEndpoint, + chainId: input.chainId, + queries: [{ + id: input.snip20ContractAddress, + contract: { + address: input.snip20ContractAddress, + codeHash: input.snip20CodeHash, + }, + queryMsg: 'GET_TRANSFER_HISTORY_MSG', + }], + }); + expect(response).toStrictEqual(transferHistoryParsed); +}); + +test('it can handle the viewing key error on the snip20 transfer history query', async () => { + const input = { + queryRouterContractAddress: 'CONTRACT_ADDRESS', + queryRouterCodeHash: 'CODE_HASH', + lcdEndpoint: 'LCD_ENDPOINT', + chainId: 'CHAIN_ID', + snip20ContractAddress: 'MOCK_ADDRESS', + snip20CodeHash: 'MOCK_CODE_HASH', + ownerAddress: 'OWNER_ADDRESS', + viewingKey: 'VIEWING_KEY', + page: 1, + pageSize: 1, + }; + + batchQuery$.mockReturnValueOnce(of(batchSnip20TransactionHistoryWithViewingKeyErrorUnparsed)); + + await expect(() => querySnip20TransferHistory(input)).rejects.toThrowError('Wrong viewing key for this address or viewing key not set'); +}); diff --git a/src/contracts/services/snip20.ts b/src/contracts/services/snip20.ts index 25906da..ed27cc9 100644 --- a/src/contracts/services/snip20.ts +++ b/src/contracts/services/snip20.ts @@ -11,7 +11,10 @@ import { TokenInfoResponse, BalanceResponse, } from '~/types/contracts/snip20/response'; -import { TokenInfo, BatchTokensInfo } from '~/types/contracts/snip20/model'; +import { + TokenInfo, + BatchTokensInfo, +} from '~/types/contracts/snip20/model'; import { Contract, BatchQueryParams, @@ -20,9 +23,14 @@ import { TransactionHistory, Snip20TransactionHistoryResponse, Snip20Tx, + Snip20TransferHistoryResponse, + BatchItemResponseStatus, } from '~/types'; import { batchQuery$ } from './batchQuery'; +/** + * parses the token info response + */ const parseTokenInfo = (response: TokenInfoResponse): TokenInfo => ({ name: response.token_info.name, symbol: response.token_info.symbol, @@ -42,6 +50,9 @@ const parseBatchQueryTokensInfo = ( blockHeight: item.blockHeight, })); +/** + * parses the balance response + */ const parseBalance = (response: BalanceResponse): string => response.balance.amount; /** @@ -225,6 +236,18 @@ const parseSnip20TransactionHistoryResponse = ( } const transactionHistoryResponse = response[0].response as Snip20TransactionHistoryResponse; + // batch query error state can be converted into a thrown error here since we only are using + // the batch query router for a single query and don't need to pass any other data through + if (response[0].status === BatchItemResponseStatus.ERROR) { + throw new Error(JSON.stringify(transactionHistoryResponse)); + } + + // check for an object as the response + if (typeof transactionHistoryResponse !== 'object') { + throw new Error(`Unexpected Response: ${JSON.stringify(transactionHistoryResponse)}`); + } + + // check for viewing key error if ('viewing_key_error' in transactionHistoryResponse) { throw new Error(transactionHistoryResponse.viewing_key_error.msg); } @@ -350,6 +373,159 @@ async function querySnip20TransactionHistory({ })); } +/** + * parses the snip20 transfer history response + */ +const parseSnip20TransferHistoryResponse = ( + response: BatchQueryParsedResponse, +): TransactionHistory => { + // validate that a single response is available, should only be true if parser is used incorrectly + if (response.length !== 1) { + throw new Error('Only one response is expected for the snip20 transaction history query'); + } + const transactionHistoryResponse = response[0].response as Snip20TransferHistoryResponse; + + // batch query error state can be converted into a thrown error here since we only are using + // the batch query router for a single query and don't need to pass any other data through + if (response[0].status === BatchItemResponseStatus.ERROR) { + throw new Error(JSON.stringify(transactionHistoryResponse)); + } + + // check for an object as the response + if (typeof transactionHistoryResponse !== 'object') { + throw new Error(`Unexpected Response: ${JSON.stringify(transactionHistoryResponse)}`); + } + + // check for viewing key error + if ('viewing_key_error' in transactionHistoryResponse) { + throw new Error(transactionHistoryResponse.viewing_key_error.msg); + } + + const parsedTxs: Snip20Tx[] = transactionHistoryResponse.transfer_history.txs.map((tx) => ({ + id: tx.id, + action: { + transfer: { + from: tx.from, + sender: tx.sender, + recipient: tx.receiver, + }, + }, + denom: tx.coins.denom, + amount: tx.coins.amount, + memo: tx.memo, + blockTime: tx.block_time, + blockHeight: tx.block_height, + })); + + return { + txs: parsedTxs, + totalTransactions: undefined, + blockHeight: response[0].blockHeight, + }; +}; + +/** + * query the snip20 transfer history. + * This function should be used for legacy snip20s that + * do not support the newer transaction history query. + */ +function querySnip20TransferHistory$({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + minBlockHeightValidationOptions, +}:{ + queryRouterContractAddress: string, + queryRouterCodeHash?: string, + lcdEndpoint?: string, + chainId?: string, + snip20ContractAddress: string, + snip20CodeHash: string, + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + minBlockHeightValidationOptions?: MinBlockHeightValidationOptions, +}) { + const query:BatchQueryParams = { + id: snip20ContractAddress, + contract: { + address: snip20ContractAddress, + codeHash: snip20CodeHash, + }, + queryMsg: snip20.queries.getTransferHistory({ + ownerAddress, + viewingKey, + page, + pageSize, + }), + }; + + return batchQuery$({ + contractAddress: queryRouterContractAddress, + codeHash: queryRouterCodeHash, + lcdEndpoint, + chainId, + queries: [query], + minBlockHeightValidationOptions, + }).pipe( + map(parseSnip20TransferHistoryResponse), + first(), + ); +} + +/** + * query the snip20 transfer history. + * This function should be used for legacy snip20s that + * do not support the newer transaction history query. + */ +async function querySnip20TransferHistory({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + minBlockHeightValidationOptions, +}:{ + queryRouterContractAddress: string, + queryRouterCodeHash?: string, + lcdEndpoint?: string, + chainId?: string, + snip20ContractAddress: string, + snip20CodeHash: string, + ownerAddress: string, + viewingKey: string, + page: number, + pageSize: number, + minBlockHeightValidationOptions?: MinBlockHeightValidationOptions, +}) { + return lastValueFrom(querySnip20TransferHistory$({ + queryRouterContractAddress, + queryRouterCodeHash, + lcdEndpoint, + chainId, + snip20ContractAddress, + snip20CodeHash, + ownerAddress, + viewingKey, + page, + pageSize, + minBlockHeightValidationOptions, + })); +} + export { querySnip20TokenInfo$, parseTokenInfo, @@ -363,4 +539,7 @@ export { querySnip20TransactionHistory$, querySnip20TransactionHistory, parseSnip20TransactionHistoryResponse, + querySnip20TransferHistory$, + querySnip20TransferHistory, + parseSnip20TransferHistoryResponse, }; diff --git a/src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts b/src/test/mocks/snip20/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts similarity index 100% rename from src/test/mocks/snip20/transactionHistory/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts rename to src/test/mocks/snip20/batchQueryTransactionHistoryWithViewingKeyErrorUnparsed.ts diff --git a/src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryParsed.ts b/src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryParsed.ts new file mode 100644 index 0000000..814574f --- /dev/null +++ b/src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryParsed.ts @@ -0,0 +1,40 @@ +import { TransactionHistory } from '~/types'; + +const transferHistoryParsed: TransactionHistory = { + txs: [{ + id: 1234567, + action: { + transfer: { + from: 'FROM_ADDRESS', + sender: 'SENDER_ADDRESS', + recipient: 'RECEIVER_ADDRESS', + }, + }, + denom: 'SSCRT', + amount: '10000', + memo: undefined, + blockTime: undefined, + blockHeight: undefined, + }, + { + id: 21345678, + action: { + transfer: { + from: 'FROM_ADDRESS', + sender: 'SENDER_ADDRESS', + recipient: 'RECEIVER_ADDRESS', + }, + }, + denom: 'SSCRT', + amount: '100000', + memo: undefined, + blockTime: undefined, + blockHeight: undefined, + }], + totalTransactions: undefined, + blockHeight: 3, +}; + +export { + transferHistoryParsed, +}; diff --git a/src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryUnparsed.ts b/src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryUnparsed.ts new file mode 100644 index 0000000..e8b17f7 --- /dev/null +++ b/src/test/mocks/snip20/transferHistory/batchQueryTransferHistoryUnparsed.ts @@ -0,0 +1,38 @@ +import { BatchQueryParsedResponse } from '~/types'; + +const batchSnip20TransferHistoryUnparsed:BatchQueryParsedResponse = [ + { + id: 'secret1k0jntykt7e4g3y88ltc60czgjuqdy4c9e8fzek', + response: { + transfer_history: { + txs: [ + { + id: 1234567, + from: 'FROM_ADDRESS', + sender: 'SENDER_ADDRESS', + receiver: 'RECEIVER_ADDRESS', + coins: { + denom: 'SSCRT', + amount: '10000', + }, + }, + { + id: 21345678, + from: 'FROM_ADDRESS', + sender: 'SENDER_ADDRESS', + receiver: 'RECEIVER_ADDRESS', + coins: { + denom: 'SSCRT', + amount: '100000', + }, + }, + ], + }, + }, + blockHeight: 3, + }, +]; + +export { + batchSnip20TransferHistoryUnparsed, +}; diff --git a/src/test/mocks/snip20/transferHistory/transferHistoryResponse.json b/src/test/mocks/snip20/transferHistory/transferHistoryResponse.json new file mode 100644 index 0000000..5faf96b --- /dev/null +++ b/src/test/mocks/snip20/transferHistory/transferHistoryResponse.json @@ -0,0 +1,26 @@ +{ + "transfer_history": { + "txs": [ + { + "id": 1234567, + "from": "FROM_ADDRESS", + "sender": "SENDER_ADDRESS", + "receiver": "RECEIVER_ADDRESS", + "coins": { + "denom": "SSCRT", + "amount": "10000" + } + }, + { + "id": 21345678, + "from": "FROM_ADDRESS", + "sender": "SENDER_ADDRESS", + "receiver": "RECEIVER_ADDRESS", + "coins": { + "denom": "SSCRT", + "amount": "100000" + } + } + ] + } +} diff --git a/src/test/mocks/snip20/transactionHistory/rawResponses/viewingKeyErrorResponse.json b/src/test/mocks/snip20/viewingKeyErrorResponse.json similarity index 100% rename from src/test/mocks/snip20/transactionHistory/rawResponses/viewingKeyErrorResponse.json rename to src/test/mocks/snip20/viewingKeyErrorResponse.json diff --git a/src/types/contracts/snip20/transactionHistory/response.ts b/src/types/contracts/snip20/transactionHistory/response.ts index 22c4780..bb35f24 100644 --- a/src/types/contracts/snip20/transactionHistory/response.ts +++ b/src/types/contracts/snip20/transactionHistory/response.ts @@ -1,4 +1,7 @@ -import { TransactionHistoryResponse as TransactionHistoryResponseSecretJS } from 'secretjs/dist/extensions/snip20/types'; +import { + TransactionHistoryResponse as TransactionHistoryResponseSecretJS, + TransferHistoryResponse as TransferHistoryResponseSecretJs, +} from 'secretjs/dist/extensions/snip20/types'; type ViewingKeyErrorResponse = { viewing_key_error: { @@ -8,6 +11,9 @@ type ViewingKeyErrorResponse = { type Snip20TransactionHistoryResponse = TransactionHistoryResponseSecretJS | ViewingKeyErrorResponse +type Snip20TransferHistoryResponse = TransferHistoryResponseSecretJs | ViewingKeyErrorResponse + export type { Snip20TransactionHistoryResponse, + Snip20TransferHistoryResponse, };