diff --git a/.circleci/config.yml b/.circleci/config.yml index f9db91d227..92a2b1d9e5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,6 +14,8 @@ jobs: working_directory: ~/app steps: + - run: sudo apt-get update; sudo apt-get -y install autoconf build-essential libgmp3-dev libtool + - checkout - run: mix local.hex --force @@ -70,6 +72,11 @@ jobs: - run: mix compile + # Ensure NIF is compiled for libsecp256k1 + - run: + command: make + working_directory: "deps/libsecp256k1" + # `deps` needs to be cached with `_build` because `_build` will symlink into `deps` - save_cache: diff --git a/apps/block_scout_web/assets/__tests__/pages/address.js b/apps/block_scout_web/assets/__tests__/pages/address.js index 7691660a40..55eedad035 100644 --- a/apps/block_scout_web/assets/__tests__/pages/address.js +++ b/apps/block_scout_web/assets/__tests__/pages/address.js @@ -1,300 +1,327 @@ import { reducer, initialState } from '../../js/pages/address' -describe('PAGE_LOAD', () => { - test('page 1 without filter', () => { - const state = initialState +describe('RECEIVED_NEW_BLOCK', () => { + test('with new block', () => { + const state = Object.assign({}, initialState, { + validationCount: 30, + validatedBlocks: [{ blockNumber: 1, blockHtml: 'test 1' }] + }) const action = { - type: 'PAGE_LOAD', - addressHash: '1234', - beyondPageOne: false, - pendingTransactionHashes: ['1'] + type: 'RECEIVED_NEW_BLOCK', + msg: { blockNumber: 2, blockHtml: 'test 2' } } const output = reducer(state, action) - expect(output.addressHash).toBe('1234') - expect(output.beyondPageOne).toBe(false) - expect(output.filter).toBe(undefined) - expect(output.pendingTransactionHashes).toEqual(['1']) + expect(output.validationCount).toEqual(31) + expect(output.validatedBlocks).toEqual([ + { blockNumber: 2, blockHtml: 'test 2' }, + { blockNumber: 1, blockHtml: 'test 1' } + ]) }) - test('page 2 without filter', () => { - const state = initialState + test('when channel has been disconnected', () => { + const state = Object.assign({}, initialState, { + channelDisconnected: true, + validationCount: 30, + validatedBlocks: [{ blockNumber: 1, blockHtml: 'test 1' }] + }) const action = { - type: 'PAGE_LOAD', - addressHash: '1234', - beyondPageOne: true, - pendingTransactionHashes: ['1'] + type: 'RECEIVED_NEW_BLOCK', + msg: { blockNumber: 2, blockHtml: 'test 2' } } const output = reducer(state, action) - expect(output.addressHash).toBe('1234') - expect(output.beyondPageOne).toBe(true) - expect(output.filter).toBe(undefined) - expect(output.pendingTransactionHashes).toEqual(['1']) + expect(output.validationCount).toEqual(30) + expect(output.validatedBlocks).toEqual([ + { blockNumber: 1, blockHtml: 'test 1' } + ]) }) - test('page 1 with "to" filter', () => { - const state = initialState + test('beyond page one', () => { + const state = Object.assign({}, initialState, { + beyondPageOne: true, + validationCount: 30, + validatedBlocks: [{ blockNumber: 1, blockHtml: 'test 1' }] + }) const action = { - type: 'PAGE_LOAD', - addressHash: '1234', - beyondPageOne: false, - filter: 'to' + type: 'RECEIVED_NEW_BLOCK', + msg: { blockNumber: 2, blockHtml: 'test 2' } } const output = reducer(state, action) - expect(output.addressHash).toBe('1234') - expect(output.beyondPageOne).toBe(false) - expect(output.filter).toBe('to') + expect(output.validationCount).toEqual(31) + expect(output.validatedBlocks).toEqual([ + { blockNumber: 1, blockHtml: 'test 1' } + ]) }) - test('page 2 with "to" filter', () => { - const state = initialState +}) + +describe('RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', () => { + test('with new internal transaction', () => { + const state = Object.assign({}, initialState, { + internalTransactions: [{ internalTransactionHtml: 'test 1' }] + }) const action = { - type: 'PAGE_LOAD', - addressHash: '1234', - beyondPageOne: true, - filter: 'to' + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: [{ internalTransactionHtml: 'test 2' }] } const output = reducer(state, action) - expect(output.addressHash).toBe('1234') - expect(output.beyondPageOne).toBe(true) - expect(output.filter).toBe('to') + expect(output.internalTransactions).toEqual([ + { internalTransactionHtml: 'test 2' }, + { internalTransactionHtml: 'test 1' } + ]) }) -}) - -test('CHANNEL_DISCONNECTED', () => { - const state = initialState - const action = { - type: 'CHANNEL_DISCONNECTED' - } - const output = reducer(state, action) - - expect(output.channelDisconnected).toBe(true) -}) - -test('RECEIVED_UPDATED_BALANCE', () => { - const state = initialState - const action = { - type: 'RECEIVED_UPDATED_BALANCE', - msg: { - balance: 'hello world' + test('with batch of new internal transactions', () => { + const state = Object.assign({}, initialState, { + internalTransactions: [{ internalTransactionHtml: 'test 1' }] + }) + const action = { + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: [ + { internalTransactionHtml: 'test 2' }, + { internalTransactionHtml: 'test 3' }, + { internalTransactionHtml: 'test 4' }, + { internalTransactionHtml: 'test 5' }, + { internalTransactionHtml: 'test 6' }, + { internalTransactionHtml: 'test 7' }, + { internalTransactionHtml: 'test 8' }, + { internalTransactionHtml: 'test 9' }, + { internalTransactionHtml: 'test 10' }, + { internalTransactionHtml: 'test 11' }, + { internalTransactionHtml: 'test 12' }, + { internalTransactionHtml: 'test 13' } + ] } - } - const output = reducer(state, action) - - expect(output.balance).toBe('hello world') -}) + const output = reducer(state, action) -describe('RECEIVED_NEW_PENDING_TRANSACTION', () => { - test('single transaction', () => { - const state = initialState + expect(output.internalTransactions).toEqual([ + { internalTransactionHtml: 'test 1' } + ]) + expect(output.internalTransactionsBatch).toEqual([ + { internalTransactionHtml: 'test 13' }, + { internalTransactionHtml: 'test 12' }, + { internalTransactionHtml: 'test 11' }, + { internalTransactionHtml: 'test 10' }, + { internalTransactionHtml: 'test 9' }, + { internalTransactionHtml: 'test 8' }, + { internalTransactionHtml: 'test 7' }, + { internalTransactionHtml: 'test 6' }, + { internalTransactionHtml: 'test 5' }, + { internalTransactionHtml: 'test 4' }, + { internalTransactionHtml: 'test 3' }, + { internalTransactionHtml: 'test 2' }, + ]) + }) + test('after batch of new internal transactions', () => { + const state = Object.assign({}, initialState, { + internalTransactionsBatch: [{ internalTransactionHtml: 'test 1' }] + }) const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION', - msg: { - transactionHash: '0x00', - transactionHtml: 'test' - } + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: [ + { internalTransactionHtml: 'test 2' } + ] } const output = reducer(state, action) - expect(output.newPendingTransactions).toEqual(['test']) - expect(output.transactionCount).toEqual(null) + expect(output.internalTransactionsBatch).toEqual([ + { internalTransactionHtml: 'test 2' }, + { internalTransactionHtml: 'test 1' } + ]) }) - test('single transaction after single transaction', () => { + test('when channel has been disconnected', () => { const state = Object.assign({}, initialState, { - newPendingTransactions: ['test 1'] + channelDisconnected: true, + internalTransactions: [{ internalTransactionHtml: 'test 1' }] }) const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION', - msg: { - transactionHash: '0x02', - transactionHtml: 'test 2' - } + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: [{ internalTransactionHtml: 'test 2' }] } const output = reducer(state, action) - expect(output.newPendingTransactions).toEqual(['test 1', 'test 2']) - expect(output.pendingTransactionHashes.length).toEqual(1) + expect(output.internalTransactions).toEqual([ + { internalTransactionHtml: 'test 1' } + ]) }) - test('after disconnection', () => { + test('beyond page one', () => { const state = Object.assign({}, initialState, { - channelDisconnected: true + beyondPageOne: true, + internalTransactions: [{ internalTransactionHtml: 'test 1' }] }) const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION', - msg: { - transactionHash: '0x00', - transactionHtml: 'test' - } + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: [{ internalTransactionHtml: 'test 2' }] } const output = reducer(state, action) - expect(output.newPendingTransactions).toEqual([]) - expect(output.pendingTransactionHashes).toEqual([]) + expect(output.internalTransactions).toEqual([ + { internalTransactionHtml: 'test 1' } + ]) }) - test('on page 2', () => { + test('with filtered out internal transaction', () => { const state = Object.assign({}, initialState, { - beyondPageOne: true + filter: 'to' }) const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION', - msg: { - transactionHash: '0x00', - transactionHtml: 'test' - } + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: [{ internalTransactionHtml: 'test 2' }] } const output = reducer(state, action) - expect(output.newPendingTransactions).toEqual([]) - expect(output.pendingTransactionHashes).toEqual([]) + expect(output.internalTransactions).toEqual([]) }) }) -describe('RECEIVED_NEW_TRANSACTION', () => { - test('single transaction', () => { +describe('RECEIVED_NEW_PENDING_TRANSACTION', () => { + test('with new pending transaction', () => { const state = Object.assign({}, initialState, { - addressHash: '0x111' + pendingTransactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] }) const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHtml: 'test' - } + type: 'RECEIVED_NEW_PENDING_TRANSACTION', + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([{ transactionHtml: 'test' }]) - expect(output.transactionCount).toEqual(null) + expect(output.pendingTransactions).toEqual([ + { transactionHash: 2, transactionHtml: 'test 2' }, + { transactionHash: 1, transactionHtml: 'test 1' } + ]) }) - test('single transaction after single transaction', () => { + test('when channel has been disconnected', () => { const state = Object.assign({}, initialState, { - newTransactions: [{ transactionHtml: 'test 1' }] + channelDisconnected: true, + pendingTransactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] }) const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHtml: 'test 2' - } + type: 'RECEIVED_NEW_PENDING_TRANSACTION', + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([ - { transactionHtml: 'test 1' }, - { transactionHtml: 'test 2' } + expect(output.pendingTransactions).toEqual([ + { transactionHash: 1, transactionHtml: 'test 1' } ]) }) - test('after disconnection', () => { + test('beyond page one', () => { const state = Object.assign({}, initialState, { - channelDisconnected: true + beyondPageOne: true, + pendingTransactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] }) const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHtml: 'test' - } + type: 'RECEIVED_NEW_PENDING_TRANSACTION', + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([]) + expect(output.pendingTransactions).toEqual([ + { transactionHash: 1, transactionHtml: 'test 1' } + ]) }) - test('on page 2', () => { + test('with filtered out pending transaction', () => { const state = Object.assign({}, initialState, { - beyondPageOne: true, - transactionCount: 1, - addressHash: '0x111' + filter: 'to' }) const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHtml: 'test' - } + type: 'RECEIVED_NEW_PENDING_TRANSACTION', + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([]) - expect(output.transactionCount).toEqual(1) + expect(output.pendingTransactions).toEqual([]) }) - test('transaction from current address with "from" filter', () => { +}) + +describe('RECEIVED_NEW_TRANSACTION', () => { + test('with new transaction', () => { const state = Object.assign({}, initialState, { - addressHash: '1234', - filter: 'from' + pendingTransactions: [{ transactionHash: 2, transactionHtml: 'test' }], + transactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] }) const action = { type: 'RECEIVED_NEW_TRANSACTION', - msg: { - fromAddressHash: '1234', - transactionHtml: 'test' - } + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([ - { fromAddressHash: '1234', transactionHtml: 'test' } + expect(output.pendingTransactions).toEqual([ + { transactionHash: 2, transactionHtml: 'test 2', validated: true } + ]) + expect(output.transactions).toEqual([ + { transactionHash: 2, transactionHtml: 'test 2' }, + { transactionHash: 1, transactionHtml: 'test 1' } ]) }) - test('transaction from current address with "to" filter', () => { + test('when channel has been disconnected', () => { const state = Object.assign({}, initialState, { - addressHash: '1234', - filter: 'to' + channelDisconnected: true, + pendingTransactions: [{ transactionHash: 2, transactionHtml: 'test' }], + transactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] }) const action = { type: 'RECEIVED_NEW_TRANSACTION', - msg: { - fromAddressHash: '1234', - transactionHtml: 'test' - } + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([]) + expect(output.pendingTransactions).toEqual([ + { transactionHash: 2, transactionHtml: 'test' } + ]) + expect(output.transactions).toEqual([ + { transactionHash: 1, transactionHtml: 'test 1' } + ]) }) - test('transaction to current address with "to" filter', () => { + test('beyond page one', () => { const state = Object.assign({}, initialState, { - addressHash: '1234', - filter: 'to' + beyondPageOne: true, + transactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] }) const action = { type: 'RECEIVED_NEW_TRANSACTION', - msg: { - toAddressHash: '1234', - transactionHtml: 'test' - } + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([ - { toAddressHash: '1234', transactionHtml: 'test' } + expect(output.pendingTransactions).toEqual([]) + expect(output.transactions).toEqual([ + { transactionHash: 1, transactionHtml: 'test 1' } ]) }) - test('transaction to current address with "from" filter', () => { + test('with filtered out transaction', () => { const state = Object.assign({}, initialState, { - addressHash: '1234', - filter: 'from' + filter: 'to' }) const action = { type: 'RECEIVED_NEW_TRANSACTION', - msg: { - toAddressHash: '1234', - transactionHtml: 'test' - } + msg: { transactionHash: 2, transactionHtml: 'test 2' } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([]) + expect(output.transactions).toEqual([]) }) - test('single transaction collated from pending', () => { - const state = initialState +}) + +describe('RECEIVED_NEXT_TRANSACTIONS_PAGE', () => { + test('with new transaction page', () => { + const state = Object.assign({}, initialState, { + loadingNextPage: true, + nextPageUrl: '1', + transactions: [{ transactionHash: 1, transactionHtml: 'test 1' }] + }) const action = { - type: 'RECEIVED_NEW_TRANSACTION', + type: 'RECEIVED_NEXT_TRANSACTIONS_PAGE', msg: { - transactionHash: '0x00', - transactionHtml: 'test' + nextPageUrl: '2', + transactions: [{ transactionHash: 2, transactionHtml: 'test 2' }] } } const output = reducer(state, action) - expect(output.newTransactions).toEqual([ - { transactionHash: '0x00', transactionHtml: 'test' } + expect(output.loadingNextPage).toEqual(false) + expect(output.nextPageUrl).toEqual('2') + expect(output.transactions).toEqual([ + { transactionHash: 1, transactionHtml: 'test 1' }, + { transactionHash: 2, transactionHtml: 'test 2' } ]) - expect(output.transactionCount).toEqual(null) }) }) diff --git a/apps/block_scout_web/assets/__tests__/pages/block.js b/apps/block_scout_web/assets/__tests__/pages/blocks.js similarity index 98% rename from apps/block_scout_web/assets/__tests__/pages/block.js rename to apps/block_scout_web/assets/__tests__/pages/blocks.js index d854fb9746..2ddad6a5a3 100644 --- a/apps/block_scout_web/assets/__tests__/pages/block.js +++ b/apps/block_scout_web/assets/__tests__/pages/blocks.js @@ -1,4 +1,4 @@ -import { reducer, initialState } from '../../js/pages/block' +import { reducer, initialState } from '../../js/pages/blocks' test('CHANNEL_DISCONNECTED', () => { const state = initialState diff --git a/apps/block_scout_web/assets/__tests__/pages/pending_transactions.js b/apps/block_scout_web/assets/__tests__/pages/pending_transactions.js new file mode 100644 index 0000000000..25d03aed61 --- /dev/null +++ b/apps/block_scout_web/assets/__tests__/pages/pending_transactions.js @@ -0,0 +1,232 @@ +import { reducer, initialState } from '../../js/pages/pending_transactions' + +test('CHANNEL_DISCONNECTED', () => { + const state = initialState + const action = { + type: 'CHANNEL_DISCONNECTED' + } + const output = reducer(state, action) + + expect(output.channelDisconnected).toBe(true) +}) + +describe('RECEIVED_NEW_PENDING_TRANSACTION_BATCH', () => { + test('single transaction', () => { + const state = initialState + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x00', + transactionHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual(['test']) + expect(output.newPendingTransactionHashesBatch.length).toEqual(0) + expect(output.pendingTransactionCount).toEqual(1) + }) + test('large batch of transactions', () => { + const state = initialState + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x01', + transactionHtml: 'test 1' + },{ + transactionHash: '0x02', + transactionHtml: 'test 2' + },{ + transactionHash: '0x03', + transactionHtml: 'test 3' + },{ + transactionHash: '0x04', + transactionHtml: 'test 4' + },{ + transactionHash: '0x05', + transactionHtml: 'test 5' + },{ + transactionHash: '0x06', + transactionHtml: 'test 6' + },{ + transactionHash: '0x07', + transactionHtml: 'test 7' + },{ + transactionHash: '0x08', + transactionHtml: 'test 8' + },{ + transactionHash: '0x09', + transactionHtml: 'test 9' + },{ + transactionHash: '0x10', + transactionHtml: 'test 10' + },{ + transactionHash: '0x11', + transactionHtml: 'test 11' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual([]) + expect(output.newPendingTransactionHashesBatch.length).toEqual(11) + expect(output.pendingTransactionCount).toEqual(11) + }) + test('single transaction after single transaction', () => { + const state = Object.assign({}, initialState, { + newPendingTransactions: ['test 1'], + pendingTransactionCount: 1 + }) + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x02', + transactionHtml: 'test 2' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual(['test 1', 'test 2']) + expect(output.newPendingTransactionHashesBatch.length).toEqual(0) + expect(output.pendingTransactionCount).toEqual(2) + }) + test('single transaction after large batch of transactions', () => { + const state = Object.assign({}, initialState, { + newPendingTransactionHashesBatch: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] + }) + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x12', + transactionHtml: 'test 12' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual([]) + expect(output.newPendingTransactionHashesBatch.length).toEqual(12) + }) + test('large batch of transactions after large batch of transactions', () => { + const state = Object.assign({}, initialState, { + newPendingTransactionHashesBatch: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] + }) + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x12', + transactionHtml: 'test 12' + },{ + transactionHash: '0x13', + transactionHtml: 'test 13' + },{ + transactionHash: '0x14', + transactionHtml: 'test 14' + },{ + transactionHash: '0x15', + transactionHtml: 'test 15' + },{ + transactionHash: '0x16', + transactionHtml: 'test 16' + },{ + transactionHash: '0x17', + transactionHtml: 'test 17' + },{ + transactionHash: '0x18', + transactionHtml: 'test 18' + },{ + transactionHash: '0x19', + transactionHtml: 'test 19' + },{ + transactionHash: '0x20', + transactionHtml: 'test 20' + },{ + transactionHash: '0x21', + transactionHtml: 'test 21' + },{ + transactionHash: '0x22', + transactionHtml: 'test 22' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual([]) + expect(output.newPendingTransactionHashesBatch.length).toEqual(22) + }) + test('after disconnection', () => { + const state = Object.assign({}, initialState, { + channelDisconnected: true + }) + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x00', + transactionHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual([]) + }) + test('on page 2+', () => { + const state = Object.assign({}, initialState, { + beyondPageOne: true, + pendingTransactionCount: 1 + }) + const action = { + type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', + msgs: [{ + transactionHash: '0x00', + transactionHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newPendingTransactions).toEqual([]) + expect(output.pendingTransactionCount).toEqual(2) + }) +}) + +describe('RECEIVED_NEW_TRANSACTION', () => { + test('single transaction collated', () => { + const state = { ...initialState, pendingTransactionCount: 2 } + const action = { + type: 'RECEIVED_NEW_TRANSACTION', + msg: { + transactionHash: '0x00' + } + } + const output = reducer(state, action) + + expect(output.pendingTransactionCount).toBe(1) + expect(output.newTransactionHashes).toEqual(['0x00']) + }) + test('single transaction collated after batch', () => { + const state = Object.assign({}, initialState, { + newPendingTransactionHashesBatch: ['0x01', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION', + msg: { + transactionHash: '0x01' + } + } + const output = reducer(state, action) + + expect(output.newPendingTransactionHashesBatch.length).toEqual(10) + expect(output.newPendingTransactionHashesBatch).not.toContain('0x01') + }) + test('on page 2+', () => { + const state = Object.assign({}, initialState, { + beyondPageOne: true, + pendingTransactionCount: 2 + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION', + msg: { + transactionHash: '0x01' + } + } + const output = reducer(state, action) + + expect(output.pendingTransactionCount).toEqual(1) + }) +}) diff --git a/apps/block_scout_web/assets/__tests__/pages/transaction.js b/apps/block_scout_web/assets/__tests__/pages/transaction.js index ea4eb3595f..55aa68f4b4 100644 --- a/apps/block_scout_web/assets/__tests__/pages/transaction.js +++ b/apps/block_scout_web/assets/__tests__/pages/transaction.js @@ -1,16 +1,5 @@ import { reducer, initialState } from '../../js/pages/transaction' -test('CHANNEL_DISCONNECTED', () => { - const state = initialState - const action = { - type: 'CHANNEL_DISCONNECTED' - } - const output = reducer(state, action) - - expect(output.channelDisconnected).toBe(true) - expect(output.batchCountAccumulator).toBe(0) -}) - test('RECEIVED_NEW_BLOCK', () => { const state = { ...initialState, blockNumber: 1 } const action = { @@ -23,374 +12,3 @@ test('RECEIVED_NEW_BLOCK', () => { expect(output.confirmations).toBe(4) }) - -describe('RECEIVED_NEW_PENDING_TRANSACTION_BATCH', () => { - test('single transaction', () => { - const state = initialState - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x00', - transactionHtml: 'test' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual(['test']) - expect(output.newPendingTransactionHashesBatch.length).toEqual(0) - expect(output.pendingTransactionCount).toEqual(1) - }) - test('large batch of transactions', () => { - const state = initialState - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x01', - transactionHtml: 'test 1' - },{ - transactionHash: '0x02', - transactionHtml: 'test 2' - },{ - transactionHash: '0x03', - transactionHtml: 'test 3' - },{ - transactionHash: '0x04', - transactionHtml: 'test 4' - },{ - transactionHash: '0x05', - transactionHtml: 'test 5' - },{ - transactionHash: '0x06', - transactionHtml: 'test 6' - },{ - transactionHash: '0x07', - transactionHtml: 'test 7' - },{ - transactionHash: '0x08', - transactionHtml: 'test 8' - },{ - transactionHash: '0x09', - transactionHtml: 'test 9' - },{ - transactionHash: '0x10', - transactionHtml: 'test 10' - },{ - transactionHash: '0x11', - transactionHtml: 'test 11' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual([]) - expect(output.newPendingTransactionHashesBatch.length).toEqual(11) - expect(output.pendingTransactionCount).toEqual(11) - }) - test('single transaction after single transaction', () => { - const state = Object.assign({}, initialState, { - newPendingTransactions: ['test 1'], - pendingTransactionCount: 1 - }) - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x02', - transactionHtml: 'test 2' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual(['test 1', 'test 2']) - expect(output.newPendingTransactionHashesBatch.length).toEqual(0) - expect(output.pendingTransactionCount).toEqual(2) - }) - test('single transaction after large batch of transactions', () => { - const state = Object.assign({}, initialState, { - newPendingTransactionHashesBatch: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] - }) - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x12', - transactionHtml: 'test 12' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual([]) - expect(output.newPendingTransactionHashesBatch.length).toEqual(12) - }) - test('large batch of transactions after large batch of transactions', () => { - const state = Object.assign({}, initialState, { - newPendingTransactionHashesBatch: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] - }) - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x12', - transactionHtml: 'test 12' - },{ - transactionHash: '0x13', - transactionHtml: 'test 13' - },{ - transactionHash: '0x14', - transactionHtml: 'test 14' - },{ - transactionHash: '0x15', - transactionHtml: 'test 15' - },{ - transactionHash: '0x16', - transactionHtml: 'test 16' - },{ - transactionHash: '0x17', - transactionHtml: 'test 17' - },{ - transactionHash: '0x18', - transactionHtml: 'test 18' - },{ - transactionHash: '0x19', - transactionHtml: 'test 19' - },{ - transactionHash: '0x20', - transactionHtml: 'test 20' - },{ - transactionHash: '0x21', - transactionHtml: 'test 21' - },{ - transactionHash: '0x22', - transactionHtml: 'test 22' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual([]) - expect(output.newPendingTransactionHashesBatch.length).toEqual(22) - }) - test('after disconnection', () => { - const state = Object.assign({}, initialState, { - channelDisconnected: true - }) - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x00', - transactionHtml: 'test' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(0) - }) - test('on page 2+', () => { - const state = Object.assign({}, initialState, { - beyondPageOne: true, - pendingTransactionCount: 1 - }) - const action = { - type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', - msgs: [{ - transactionHash: '0x00', - transactionHtml: 'test' - }] - } - const output = reducer(state, action) - - expect(output.newPendingTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(0) - expect(output.pendingTransactionCount).toEqual(2) - }) -}) - -describe('RECEIVED_NEW_TRANSACTION', () => { - test('single transaction collated', () => { - const state = { ...initialState, pendingTransactionCount: 2 } - const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHash: '0x00' - } - } - const output = reducer(state, action) - - expect(output.pendingTransactionCount).toBe(1) - expect(output.newTransactionHashes).toEqual(['0x00']) - }) - test('single transaction collated after batch', () => { - const state = Object.assign({}, initialState, { - newPendingTransactionHashesBatch: ['0x01', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHash: '0x01' - } - } - const output = reducer(state, action) - - expect(output.newPendingTransactionHashesBatch.length).toEqual(10) - expect(output.newPendingTransactionHashesBatch).not.toContain('0x01') - }) - test('on page 2+', () => { - const state = Object.assign({}, initialState, { - beyondPageOne: true, - pendingTransactionCount: 2 - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION', - msg: { - transactionHash: '0x01' - } - } - const output = reducer(state, action) - - expect(output.pendingTransactionCount).toEqual(1) - }) -}) - -describe('RECEIVED_NEW_TRANSACTION_BATCH', () => { - test('single transaction', () => { - const state = initialState - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual(['test']) - expect(output.batchCountAccumulator).toEqual(0) - expect(output.transactionCount).toEqual(1) - }) - test('large batch of transactions', () => { - const state = initialState - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test 1' - },{ - transactionHtml: 'test 2' - },{ - transactionHtml: 'test 3' - },{ - transactionHtml: 'test 4' - },{ - transactionHtml: 'test 5' - },{ - transactionHtml: 'test 6' - },{ - transactionHtml: 'test 7' - },{ - transactionHtml: 'test 8' - },{ - transactionHtml: 'test 9' - },{ - transactionHtml: 'test 10' - },{ - transactionHtml: 'test 11' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(11) - expect(output.transactionCount).toEqual(11) - }) - test('single transaction after single transaction', () => { - const state = Object.assign({}, initialState, { - newTransactions: ['test 1'] - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test 2' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual(['test 1', 'test 2']) - expect(output.batchCountAccumulator).toEqual(0) - }) - test('single transaction after large batch of transactions', () => { - const state = Object.assign({}, initialState, { - batchCountAccumulator: 11 - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test 12' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(12) - }) - test('large batch of transactions after large batch of transactions', () => { - const state = Object.assign({}, initialState, { - batchCountAccumulator: 11 - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test 12' - },{ - transactionHtml: 'test 13' - },{ - transactionHtml: 'test 14' - },{ - transactionHtml: 'test 15' - },{ - transactionHtml: 'test 16' - },{ - transactionHtml: 'test 17' - },{ - transactionHtml: 'test 18' - },{ - transactionHtml: 'test 19' - },{ - transactionHtml: 'test 20' - },{ - transactionHtml: 'test 21' - },{ - transactionHtml: 'test 22' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(22) - }) - test('after disconnection', () => { - const state = Object.assign({}, initialState, { - channelDisconnected: true - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(0) - }) - test('on page 2+', () => { - const state = Object.assign({}, initialState, { - beyondPageOne: true, - transactionCount: 1 - }) - const action = { - type: 'RECEIVED_NEW_TRANSACTION_BATCH', - msgs: [{ - transactionHtml: 'test' - }] - } - const output = reducer(state, action) - - expect(output.newTransactions).toEqual([]) - expect(output.batchCountAccumulator).toEqual(0) - expect(output.transactionCount).toEqual(2) - }) -}) diff --git a/apps/block_scout_web/assets/__tests__/pages/transactions.js b/apps/block_scout_web/assets/__tests__/pages/transactions.js new file mode 100644 index 0000000000..f45a4960d4 --- /dev/null +++ b/apps/block_scout_web/assets/__tests__/pages/transactions.js @@ -0,0 +1,160 @@ +import { reducer, initialState } from '../../js/pages/transactions' + +test('CHANNEL_DISCONNECTED', () => { + const state = initialState + const action = { + type: 'CHANNEL_DISCONNECTED' + } + const output = reducer(state, action) + + expect(output.channelDisconnected).toBe(true) + expect(output.batchCountAccumulator).toBe(0) +}) + +describe('RECEIVED_NEW_TRANSACTION_BATCH', () => { + test('single transaction', () => { + const state = initialState + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual(['test']) + expect(output.batchCountAccumulator).toEqual(0) + expect(output.transactionCount).toEqual(1) + }) + test('large batch of transactions', () => { + const state = initialState + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test 1' + },{ + transactionHtml: 'test 2' + },{ + transactionHtml: 'test 3' + },{ + transactionHtml: 'test 4' + },{ + transactionHtml: 'test 5' + },{ + transactionHtml: 'test 6' + },{ + transactionHtml: 'test 7' + },{ + transactionHtml: 'test 8' + },{ + transactionHtml: 'test 9' + },{ + transactionHtml: 'test 10' + },{ + transactionHtml: 'test 11' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual([]) + expect(output.batchCountAccumulator).toEqual(11) + expect(output.transactionCount).toEqual(11) + }) + test('single transaction after single transaction', () => { + const state = Object.assign({}, initialState, { + newTransactions: ['test 1'] + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test 2' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual(['test 1', 'test 2']) + expect(output.batchCountAccumulator).toEqual(0) + }) + test('single transaction after large batch of transactions', () => { + const state = Object.assign({}, initialState, { + batchCountAccumulator: 11 + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test 12' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual([]) + expect(output.batchCountAccumulator).toEqual(12) + }) + test('large batch of transactions after large batch of transactions', () => { + const state = Object.assign({}, initialState, { + batchCountAccumulator: 11 + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test 12' + },{ + transactionHtml: 'test 13' + },{ + transactionHtml: 'test 14' + },{ + transactionHtml: 'test 15' + },{ + transactionHtml: 'test 16' + },{ + transactionHtml: 'test 17' + },{ + transactionHtml: 'test 18' + },{ + transactionHtml: 'test 19' + },{ + transactionHtml: 'test 20' + },{ + transactionHtml: 'test 21' + },{ + transactionHtml: 'test 22' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual([]) + expect(output.batchCountAccumulator).toEqual(22) + }) + test('after disconnection', () => { + const state = Object.assign({}, initialState, { + channelDisconnected: true + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual([]) + expect(output.batchCountAccumulator).toEqual(0) + }) + test('on page 2+', () => { + const state = Object.assign({}, initialState, { + beyondPageOne: true, + transactionCount: 1 + }) + const action = { + type: 'RECEIVED_NEW_TRANSACTION_BATCH', + msgs: [{ + transactionHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newTransactions).toEqual([]) + expect(output.batchCountAccumulator).toEqual(0) + expect(output.transactionCount).toEqual(2) + }) +}) diff --git a/apps/block_scout_web/assets/js/app.js b/apps/block_scout_web/assets/js/app.js index 935a325d04..83e3080c5c 100644 --- a/apps/block_scout_web/assets/js/app.js +++ b/apps/block_scout_web/assets/js/app.js @@ -20,6 +20,13 @@ import 'bootstrap' import './locale' +import './pages/address' +import './pages/blocks' +import './pages/chain' +import './pages/pending_transactions' +import './pages/transaction' +import './pages/transactions' + import './lib/clipboard_buttons' import './lib/currency' import './lib/from_now' @@ -37,8 +44,3 @@ import './lib/token_balance_dropdown_search' import './lib/token_transfers_toggle' import './lib/tooltip' import './lib/try_api' - -import './pages/address' -import './pages/block' -import './pages/chain' -import './pages/transaction' diff --git a/apps/block_scout_web/assets/js/lib/from_now.js b/apps/block_scout_web/assets/js/lib/from_now.js index ce00071d21..d0f1d57f63 100644 --- a/apps/block_scout_web/assets/js/lib/from_now.js +++ b/apps/block_scout_web/assets/js/lib/from_now.js @@ -8,8 +8,9 @@ moment.relativeTimeThreshold('m', 60) moment.relativeTimeThreshold('s', 60) moment.relativeTimeThreshold('ss', 1) -export function updateAllAges () { - $('[data-from-now]').each((i, el) => tryUpdateAge(el)) +export function updateAllAges ($container = $(document)) { + $container.find('[data-from-now]').each((i, el) => tryUpdateAge(el)) + return $container } function tryUpdateAge (el) { if (!el.dataset.fromNow) return diff --git a/apps/block_scout_web/assets/js/pages/address.js b/apps/block_scout_web/assets/js/pages/address.js index c3ed5a9ba7..fb969ac73f 100644 --- a/apps/block_scout_web/assets/js/pages/address.js +++ b/apps/block_scout_web/assets/js/pages/address.js @@ -4,47 +4,48 @@ import URI from 'urijs' import humps from 'humps' import numeral from 'numeral' import socket from '../socket' -import { batchChannel, initRedux, slideDownPrepend, slideUpRemove } from '../utils' -import { updateAllAges } from '../lib/from_now' +import { createStore, connectElements, batchChannel, listMorph, onScrollBottom } from '../utils' import { updateAllCalculatedUsdValues } from '../lib/currency.js' import { loadTokenBalanceDropdown } from '../lib/token_balance_dropdown' const BATCH_THRESHOLD = 10 +const TRANSACTION_VALIDATED_MOVE_DELAY = 1000 export const initialState = { - addressHash: null, - balance: null, - batchCountAccumulator: 0, - beyondPageOne: null, channelDisconnected: false, + + addressHash: null, filter: null, - newBlock: null, - newInternalTransactions: [], - newPendingTransactions: [], - newTransactions: [], - pendingTransactionHashes: [], + + balance: null, transactionCount: null, - validationCount: null + validationCount: null, + + pendingTransactions: [], + transactions: [], + internalTransactions: [], + internalTransactionsBatch: [], + validatedBlocks: [], + + loadingNextPage: false, + pagingError: false, + nextPageUrl: null, + + beyondPageOne: null } export function reducer (state = initialState, action) { switch (action.type) { - case 'PAGE_LOAD': { - return Object.assign({}, state, { - addressHash: action.addressHash, - beyondPageOne: action.beyondPageOne, - filter: action.filter, - pendingTransactionHashes: action.pendingTransactionHashes, - transactionCount: numeral(action.transactionCount).value(), - validationCount: action.validationCount ? numeral(action.validationCount).value() : null - }) + case 'PAGE_LOAD': + case 'ELEMENTS_LOAD': { + return Object.assign({}, state, _.omit(action, 'type')) } case 'CHANNEL_DISCONNECTED': { if (state.beyondPageOne) return state return Object.assign({}, state, { channelDisconnected: true, - batchCountAccumulator: 0 + internalTransactionsBatch: [] }) } case 'RECEIVED_NEW_BLOCK': { @@ -54,7 +55,10 @@ export function reducer (state = initialState, action) { if (state.beyondPageOne) return Object.assign({}, state, { validationCount }) return Object.assign({}, state, { - newBlock: action.msg.blockHtml, + validatedBlocks: [ + action.msg, + ...state.validatedBlocks + ], validationCount }) } @@ -68,16 +72,19 @@ export function reducer (state = initialState, action) { (state.filter === 'from' && fromAddressHash === state.addressHash) )) - if (!state.batchCountAccumulator && incomingInternalTransactions.length < BATCH_THRESHOLD) { + if (!state.internalTransactionsBatch.length && incomingInternalTransactions.length < BATCH_THRESHOLD) { return Object.assign({}, state, { - newInternalTransactions: [ - ...state.newInternalTransactions, - ..._.map(incomingInternalTransactions, 'internalTransactionHtml') + internalTransactions: [ + ...incomingInternalTransactions.reverse(), + ...state.internalTransactions ] }) } else { return Object.assign({}, state, { - batchCountAccumulator: state.batchCountAccumulator + incomingInternalTransactions.length + internalTransactionsBatch: [ + ...incomingInternalTransactions.reverse(), + ...state.internalTransactionsBatch + ] }) } } @@ -90,16 +97,17 @@ export function reducer (state = initialState, action) { } return Object.assign({}, state, { - newPendingTransactions: [ - ...state.newPendingTransactions, - action.msg.transactionHtml - ], - pendingTransactionHashes: [ - ...state.pendingTransactionHashes, - action.msg.transactionHash + pendingTransactions: [ + action.msg, + ...state.pendingTransactions ] }) } + case 'REMOVE_PENDING_TRANSACTION': { + return Object.assign({}, state, { + pendingTransactions: state.pendingTransactions.filter((transaction) => action.msg.transactionHash !== transaction.transactionHash) + }) + } case 'RECEIVED_NEW_TRANSACTION': { if (state.channelDisconnected) return state @@ -111,15 +119,12 @@ export function reducer (state = initialState, action) { return Object.assign({}, state, { transactionCount }) } - const updatedPendingTransactionHashes = - _.without(state.pendingTransactionHashes, action.msg.transactionHash) - return Object.assign({}, state, { - newTransactions: [ - ...state.newTransactions, - action.msg + pendingTransactions: state.pendingTransactions.map((transaction) => action.msg.transactionHash === transaction.transactionHash ? Object.assign({}, action.msg, { validated: true }) : transaction), + transactions: [ + action.msg, + ...state.transactions ], - pendingTransactionHashes: updatedPendingTransactionHashes, transactionCount: transactionCount }) } @@ -128,109 +133,245 @@ export function reducer (state = initialState, action) { balance: action.msg.balance }) } + case 'LOADING_NEXT_PAGE': { + return Object.assign({}, state, { + loadingNextPage: true + }) + } + case 'PAGING_ERROR': { + return Object.assign({}, state, { + loadingNextPage: false, + pagingError: true + }) + } + case 'RECEIVED_NEXT_TRANSACTIONS_PAGE': { + return Object.assign({}, state, { + loadingNextPage: false, + nextPageUrl: action.msg.nextPageUrl, + transactions: [ + ...state.transactions, + ...action.msg.transactions + ] + }) + } default: return state } } -const $addressDetailsPage = $('[data-page="address-details"]') -if ($addressDetailsPage.length) { - initRedux(reducer, { - main (store) { - const addressHash = $addressDetailsPage[0].dataset.pageAddressHash - const addressChannel = socket.channel(`addresses:${addressHash}`, {}) - const { filter, blockNumber } = humps.camelizeKeys(URI(window.location).query(true)) - store.dispatch({ - type: 'PAGE_LOAD', - addressHash, - beyondPageOne: !!blockNumber, - filter, - pendingTransactionHashes: $('[data-selector="pending-transactions-list"]').children() - .map((index, el) => el.dataset.transactionHash).toArray(), - transactionCount: $('[data-selector="transaction-count"]').text(), - validationCount: $('[data-selector="validation-count"]') ? $('[data-selector="validation-count"]').text() : null - }) - addressChannel.join() - addressChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) - addressChannel.on('balance', (msg) => { - store.dispatch({ type: 'RECEIVED_UPDATED_BALANCE', msg: humps.camelizeKeys(msg) }) - }) - addressChannel.on('internal_transaction', batchChannel((msgs) => - store.dispatch({ type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', msgs: humps.camelizeKeys(msgs) }) - )) - addressChannel.on('pending_transaction', (msg) => store.dispatch({ type: 'RECEIVED_NEW_PENDING_TRANSACTION', msg: humps.camelizeKeys(msg) })) - addressChannel.on('transaction', (msg) => store.dispatch({ type: 'RECEIVED_NEW_TRANSACTION', msg: humps.camelizeKeys(msg) })) - const blocksChannel = socket.channel(`blocks:${addressHash}`, {}) - blocksChannel.join() - blocksChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) - blocksChannel.on('new_block', (msg) => store.dispatch({ type: 'RECEIVED_NEW_BLOCK', msg: humps.camelizeKeys(msg) })) +const elements = { + '[data-selector="channel-disconnected-message"]': { + render ($el, state) { + if (state.channelDisconnected) $el.show() + } + }, + '[data-selector="balance-card"]': { + load ($el) { + return { balance: $el.html() } }, - render (state, oldState) { - const $balance = $('[data-selector="balance-card"]') - const $channelBatching = $('[data-selector="channel-batching-message"]') - const $channelBatchingCount = $('[data-selector="channel-batching-count"]') - const $channelDisconnected = $('[data-selector="channel-disconnected-message"]') - const $emptyInternalTransactionsList = $('[data-selector="empty-internal-transactions-list"]') - const $emptyTransactionsList = $('[data-selector="empty-transactions-list"]') - const $internalTransactionsList = $('[data-selector="internal-transactions-list"]') - const $pendingTransactionsCount = $('[data-selector="pending-transactions-count"]') - const $pendingTransactionsList = $('[data-selector="pending-transactions-list"]') - const $transactionCount = $('[data-selector="transaction-count"]') - const $transactionsList = $('[data-selector="transactions-list"]') - const $validationCount = $('[data-selector="validation-count"]') - const $validationsList = $('[data-selector="validations-list"]') - - if ($emptyInternalTransactionsList.length && state.newInternalTransactions.length) window.location.reload() - if ($emptyTransactionsList.length && state.newTransactions.length) window.location.reload() - if (state.channelDisconnected) $channelDisconnected.show() - if (oldState.balance !== state.balance) { - $balance.empty().append(state.balance) - loadTokenBalanceDropdown() - updateAllCalculatedUsdValues() - } - if (oldState.transactionCount !== state.transactionCount) $transactionCount.empty().append(numeral(state.transactionCount).format()) - if (oldState.validationCount !== state.validationCount) $validationCount.empty().append(numeral(state.validationCount).format()) - if (state.batchCountAccumulator) { - $channelBatching.show() - $channelBatchingCount[0].innerHTML = numeral(state.batchCountAccumulator).format() + render ($el, state, oldState) { + if (oldState.balance === state.balance) return + $el.empty().append(state.balance) + loadTokenBalanceDropdown() + updateAllCalculatedUsdValues() + } + }, + '[data-selector="transaction-count"]': { + load ($el) { + return { transactionCount: numeral($el.text()).value() } + }, + render ($el, state, oldState) { + if (oldState.transactionCount === state.transactionCount) return + $el.empty().append(numeral(state.transactionCount).format()) + } + }, + '[data-selector="validation-count"]': { + load ($el) { + return { validationCount: numeral($el.text()).value } + }, + render ($el, state, oldState) { + if (oldState.validationCount === state.validationCount) return + $el.empty().append(numeral(state.validationCount).format()) + } + }, + '[data-selector="loading-next-page"]': { + render ($el, state) { + if (state.loadingNextPage) { + $el.show() } else { - $channelBatching.hide() + $el.hide() } - if (oldState.newInternalTransactions !== state.newInternalTransactions && $internalTransactionsList.length) { - slideDownPrepend($internalTransactionsList, state.newInternalTransactions.slice(oldState.newInternalTransactions.length).reverse().join('')) - updateAllAges() + } + }, + '[data-selector="paging-error-message"]': { + render ($el, state) { + if (state.pagingError) { + $el.show() } - if (oldState.pendingTransactionHashes.length !== state.pendingTransactionHashes.length && $pendingTransactionsCount.length) { - $pendingTransactionsCount[0].innerHTML = numeral(state.pendingTransactionHashes.length).format() + } + }, + '[data-selector="pending-transactions-list"]': { + load ($el) { + return { + pendingTransactions: $el.children().map((index, el) => ({ + transactionHash: el.dataset.transactionHash, + transactionHtml: el.outerHTML + })).toArray() } - if (oldState.newPendingTransactions !== state.newPendingTransactions && $pendingTransactionsList.length) { - slideDownPrepend($pendingTransactionsList, state.newPendingTransactions.slice(oldState.newPendingTransactions.length).reverse().join('')) - updateAllAges() + }, + render ($el, state, oldState) { + if (oldState.pendingTransactions === state.pendingTransactions) return + const container = $el[0] + const newElements = _.map(state.pendingTransactions, ({ transactionHtml }) => $(transactionHtml)[0]) + listMorph(container, newElements, { key: 'dataset.transactionHash' }) + } + }, + '[data-selector="pending-transactions-count"]': { + render ($el, state, oldState) { + if (oldState.pendingTransactions === state.pendingTransactions) return + $el[0].innerHTML = numeral(state.pendingTransactions.filter(({ validated }) => !validated).length).format() + } + }, + '[data-selector="transactions-list"]': { + load ($el, store) { + return { + transactions: $el.children().map((index, el) => ({ + transactionHash: el.dataset.transactionHash, + transactionHtml: el.outerHTML + })).toArray() } - if (oldState.newTransactions !== state.newTransactions && $transactionsList.length) { - const newlyValidatedTransactions = state.newTransactions.slice(oldState.newTransactions.length).reverse() - newlyValidatedTransactions.forEach(({ transactionHash, transactionHtml }) => { - let $transaction = $(`[data-selector="pending-transactions-list"] [data-transaction-hash="${transactionHash}"]`) - $transaction.html($(transactionHtml).html()) - if ($transaction.is(':visible')) { - setTimeout(() => { - $transaction.addClass('shrink-out') - setTimeout(() => { - slideUpRemove($transaction) - slideDownPrepend($transactionsList, transactionHtml) - }, 400) - }, 1000) - } else { - $transaction.remove() - slideDownPrepend($transactionsList, transactionHtml) - } - }) - updateAllAges() + }, + render ($el, state, oldState) { + if (oldState.transactions === state.transactions) return + function updateTransactions () { + const container = $el[0] + const newElements = _.map(state.transactions, ({ transactionHtml }) => $(transactionHtml)[0]) + listMorph(container, newElements, { key: 'dataset.transactionHash' }) + } + if ($('[data-selector="pending-transactions-list"]').is(':visible')) { + setTimeout(updateTransactions, TRANSACTION_VALIDATED_MOVE_DELAY + 400) + } else { + updateTransactions() + } + } + }, + '[data-selector="internal-transactions-list"]': { + load ($el) { + return { + internalTransactions: $el.children().map((index, el) => ({ + internalTransactionHtml: el.outerHTML + })).toArray() } - if (oldState.newBlock !== state.newBlock) { - slideDownPrepend($validationsList, state.newBlock) - updateAllAges() + }, + render ($el, state, oldState) { + if (oldState.internalTransactions === state.internalTransactions) return + const container = $el[0] + const newElements = _.map(state.internalTransactions, ({ internalTransactionHtml }) => $(internalTransactionHtml)[0]) + listMorph(container, newElements, { key: 'dataset.key' }) + } + }, + '[data-selector="channel-batching-count"]': { + render ($el, state, oldState) { + const $channelBatching = $('[data-selector="channel-batching-message"]') + if (!state.internalTransactionsBatch.length) return $channelBatching.hide() + $channelBatching.show() + $el[0].innerHTML = numeral(state.internalTransactionsBatch.length).format() + } + }, + '[data-selector="validations-list"]': { + load ($el) { + return { + validatedBlocks: $el.children().map((index, el) => ({ + blockNumber: parseInt(el.dataset.blockNumber), + blockHtml: el.outerHTML + })).toArray() } + }, + render ($el, state, oldState) { + if (oldState.validatedBlocks === state.validatedBlocks) return + const container = $el[0] + const newElements = _.map(state.validatedBlocks, ({ blockHtml }) => $(blockHtml)[0]) + listMorph(container, newElements, { key: 'dataset.blockNumber' }) + } + }, + '[data-selector="next-page-button"]': { + load ($el) { + return { + nextPageUrl: `${$el.hide().attr('href')}&type=JSON` + } + } + } +} + +const $addressDetailsPage = $('[data-page="address-details"]') +if ($addressDetailsPage.length) { + const store = createStore(reducer) + const addressHash = $addressDetailsPage[0].dataset.pageAddressHash + const { filter, blockNumber } = humps.camelizeKeys(URI(window.location).query(true)) + store.dispatch({ + type: 'PAGE_LOAD', + addressHash, + filter, + beyondPageOne: !!blockNumber + }) + connectElements({ store, elements }) + + const addressChannel = socket.channel(`addresses:${addressHash}`, {}) + addressChannel.join() + addressChannel.onError(() => store.dispatch({ + type: 'CHANNEL_DISCONNECTED' + })) + addressChannel.on('balance', (msg) => store.dispatch({ + type: 'RECEIVED_UPDATED_BALANCE', + msg: humps.camelizeKeys(msg) + })) + addressChannel.on('internal_transaction', batchChannel((msgs) => store.dispatch({ + type: 'RECEIVED_NEW_INTERNAL_TRANSACTION_BATCH', + msgs: humps.camelizeKeys(msgs) + }))) + addressChannel.on('pending_transaction', (msg) => store.dispatch({ + type: 'RECEIVED_NEW_PENDING_TRANSACTION', + msg: humps.camelizeKeys(msg) + })) + addressChannel.on('transaction', (msg) => { + store.dispatch({ + type: 'RECEIVED_NEW_TRANSACTION', + msg: humps.camelizeKeys(msg) + }) + setTimeout(() => store.dispatch({ + type: 'REMOVE_PENDING_TRANSACTION', + msg: humps.camelizeKeys(msg) + }), TRANSACTION_VALIDATED_MOVE_DELAY) + }) + + const blocksChannel = socket.channel(`blocks:${addressHash}`, {}) + blocksChannel.join() + blocksChannel.onError(() => store.dispatch({ + type: 'CHANNEL_DISCONNECTED' + })) + blocksChannel.on('new_block', (msg) => store.dispatch({ + type: 'RECEIVED_NEW_BLOCK', + msg: humps.camelizeKeys(msg) + })) + + $('[data-selector="transactions-list"]').length && onScrollBottom(function loadMoreTransactions () { + const { loadingNextPage, nextPageUrl, pagingError } = store.getState() + if (!loadingNextPage && nextPageUrl && !pagingError) { + store.dispatch({ + type: 'LOADING_NEXT_PAGE' + }) + $.get(nextPageUrl) + .done(msg => { + store.dispatch({ + type: 'RECEIVED_NEXT_TRANSACTIONS_PAGE', + msg: humps.camelizeKeys(msg) + }) + }) + .fail(() => { + store.dispatch({ + type: 'PAGING_ERROR' + }) + }) } }) } diff --git a/apps/block_scout_web/assets/js/pages/block.js b/apps/block_scout_web/assets/js/pages/blocks.js similarity index 100% rename from apps/block_scout_web/assets/js/pages/block.js rename to apps/block_scout_web/assets/js/pages/blocks.js diff --git a/apps/block_scout_web/assets/js/pages/pending_transactions.js b/apps/block_scout_web/assets/js/pages/pending_transactions.js new file mode 100644 index 0000000000..75c3ef8d0f --- /dev/null +++ b/apps/block_scout_web/assets/js/pages/pending_transactions.js @@ -0,0 +1,131 @@ +import $ from 'jquery' +import _ from 'lodash' +import URI from 'urijs' +import humps from 'humps' +import numeral from 'numeral' +import socket from '../socket' +import { updateAllAges } from '../lib/from_now' +import { batchChannel, initRedux, slideDownPrepend, slideUpRemove } from '../utils' + +const BATCH_THRESHOLD = 10 + +export const initialState = { + newPendingTransactionHashesBatch: [], + beyondPageOne: null, + channelDisconnected: false, + newPendingTransactions: [], + newTransactionHashes: [], + pendingTransactionCount: null +} + +export function reducer (state = initialState, action) { + switch (action.type) { + case 'PAGE_LOAD': { + return Object.assign({}, state, { + beyondPageOne: action.beyondPageOne, + pendingTransactionCount: numeral(action.pendingTransactionCount).value() + }) + } + case 'CHANNEL_DISCONNECTED': { + return Object.assign({}, state, { + channelDisconnected: true + }) + } + case 'RECEIVED_NEW_TRANSACTION': { + if (state.channelDisconnected) return state + + return Object.assign({}, state, { + newPendingTransactionHashesBatch: _.without(state.newPendingTransactionHashesBatch, action.msg.transactionHash), + pendingTransactionCount: state.pendingTransactionCount - 1, + newTransactionHashes: [action.msg.transactionHash] + }) + } + case 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH': { + if (state.channelDisconnected) return state + + const pendingTransactionCount = state.pendingTransactionCount + action.msgs.length + + if (state.beyondPageOne) return Object.assign({}, state, { pendingTransactionCount }) + + if (!state.newPendingTransactionHashesBatch.length && action.msgs.length < BATCH_THRESHOLD) { + return Object.assign({}, state, { + newPendingTransactions: [ + ...state.newPendingTransactions, + ..._.map(action.msgs, 'transactionHtml') + ], + pendingTransactionCount + }) + } else { + return Object.assign({}, state, { + newPendingTransactionHashesBatch: [ + ...state.newPendingTransactionHashesBatch, + ..._.map(action.msgs, 'transactionHash') + ], + pendingTransactionCount + }) + } + } + default: + return state + } +} + +const $transactionPendingListPage = $('[data-page="transaction-pending-list"]') +if ($transactionPendingListPage.length) { + initRedux(reducer, { + main (store) { + store.dispatch({ + type: 'PAGE_LOAD', + pendingTransactionCount: $('[data-selector="transaction-pending-count"]').text(), + beyondPageOne: !!humps.camelizeKeys(URI(window.location).query(true)).insertedAt + }) + const transactionsChannel = socket.channel(`transactions:new_transaction`) + transactionsChannel.join() + transactionsChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) + transactionsChannel.on('transaction', (msg) => + store.dispatch({ type: 'RECEIVED_NEW_TRANSACTION', msg: humps.camelizeKeys(msg) }) + ) + const pendingTransactionsChannel = socket.channel(`transactions:new_pending_transaction`) + pendingTransactionsChannel.join() + pendingTransactionsChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) + pendingTransactionsChannel.on('pending_transaction', batchChannel((msgs) => + store.dispatch({ type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', msgs: humps.camelizeKeys(msgs) })) + ) + }, + render (state, oldState) { + const $channelBatching = $('[data-selector="channel-batching-message"]') + const $channelBatchingCount = $('[data-selector="channel-batching-count"]') + const $channelDisconnected = $('[data-selector="channel-disconnected-message"]') + const $pendingTransactionsList = $('[data-selector="transactions-pending-list"]') + const $pendingTransactionsCount = $('[data-selector="transaction-pending-count"]') + + if (state.channelDisconnected) $channelDisconnected.show() + if (oldState.pendingTransactionCount !== state.pendingTransactionCount) { + $pendingTransactionsCount.empty().append(numeral(state.pendingTransactionCount).format()) + } + if (oldState.newTransactionHashes !== state.newTransactionHashes && state.newTransactionHashes.length > 0) { + const $transaction = $(`[data-transaction-hash="${state.newTransactionHashes[0]}"]`) + $transaction.addClass('shrink-out') + setTimeout(() => { + if ($transaction.length === 1 && $transaction.siblings().length === 0 && state.pendingTransactionCount > 0) { + window.location.href = URI(window.location).removeQuery('inserted_at').removeQuery('hash').toString() + } else { + slideUpRemove($transaction) + } + }, 400) + } + if (state.newPendingTransactionHashesBatch.length) { + $channelBatching.show() + $channelBatchingCount[0].innerHTML = numeral(state.newPendingTransactionHashesBatch.length).format() + } else { + $channelBatching.hide() + } + if (oldState.newPendingTransactions !== state.newPendingTransactions) { + const newTransactionsToInsert = state.newPendingTransactions.slice(oldState.newPendingTransactions.length) + slideDownPrepend($pendingTransactionsList, newTransactionsToInsert.reverse().join('')) + + updateAllAges() + } + } + }) +} diff --git a/apps/block_scout_web/assets/js/pages/transaction.js b/apps/block_scout_web/assets/js/pages/transaction.js index beda3482c4..335a59e974 100644 --- a/apps/block_scout_web/assets/js/pages/transaction.js +++ b/apps/block_scout_web/assets/js/pages/transaction.js @@ -1,42 +1,19 @@ import $ from 'jquery' -import _ from 'lodash' -import URI from 'urijs' import humps from 'humps' import numeral from 'numeral' import socket from '../socket' -import { updateAllAges } from '../lib/from_now' -import { batchChannel, initRedux, slideDownPrepend, slideUpRemove } from '../utils' - -const BATCH_THRESHOLD = 10 +import { initRedux } from '../utils' export const initialState = { - batchCountAccumulator: 0, - newPendingTransactionHashesBatch: [], - beyondPageOne: null, blockNumber: null, - channelDisconnected: false, - confirmations: null, - newPendingTransactions: [], - newTransactions: [], - newTransactionHashes: [], - transactionCount: null, - pendingTransactionCount: null + confirmations: null } export function reducer (state = initialState, action) { switch (action.type) { case 'PAGE_LOAD': { return Object.assign({}, state, { - beyondPageOne: action.beyondPageOne, - blockNumber: parseInt(action.blockNumber, 10), - transactionCount: numeral(action.transactionCount).value(), - pendingTransactionCount: numeral(action.pendingTransactionCount).value() - }) - } - case 'CHANNEL_DISCONNECTED': { - return Object.assign({}, state, { - channelDisconnected: true, - batchCountAccumulator: 0 + blockNumber: parseInt(action.blockNumber, 10) }) } case 'RECEIVED_NEW_BLOCK': { @@ -46,62 +23,6 @@ export function reducer (state = initialState, action) { }) } else return state } - case 'RECEIVED_NEW_TRANSACTION': { - if (state.channelDisconnected) return state - - return Object.assign({}, state, { - newPendingTransactionHashesBatch: _.without(state.newPendingTransactionHashesBatch, action.msg.transactionHash), - pendingTransactionCount: state.pendingTransactionCount - 1, - newTransactionHashes: [action.msg.transactionHash] - }) - } - case 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH': { - if (state.channelDisconnected) return state - - const pendingTransactionCount = state.pendingTransactionCount + action.msgs.length - - if (state.beyondPageOne) return Object.assign({}, state, { pendingTransactionCount }) - - if (!state.newPendingTransactionHashesBatch.length && action.msgs.length < BATCH_THRESHOLD) { - return Object.assign({}, state, { - newPendingTransactions: [ - ...state.newPendingTransactions, - ..._.map(action.msgs, 'transactionHtml') - ], - pendingTransactionCount - }) - } else { - return Object.assign({}, state, { - newPendingTransactionHashesBatch: [ - ...state.newPendingTransactionHashesBatch, - ..._.map(action.msgs, 'transactionHash') - ], - pendingTransactionCount - }) - } - } - case 'RECEIVED_NEW_TRANSACTION_BATCH': { - if (state.channelDisconnected) return state - - const transactionCount = state.transactionCount + action.msgs.length - - if (state.beyondPageOne) return Object.assign({}, state, { transactionCount }) - - if (!state.batchCountAccumulator && action.msgs.length < BATCH_THRESHOLD) { - return Object.assign({}, state, { - newTransactions: [ - ...state.newTransactions, - ..._.map(action.msgs, 'transactionHtml') - ], - transactionCount - }) - } else { - return Object.assign({}, state, { - batchCountAccumulator: state.batchCountAccumulator + action.msgs.length, - transactionCount - }) - } - } default: return state } @@ -134,104 +55,3 @@ if ($transactionDetailsPage.length) { } }) } - -const $transactionPendingListPage = $('[data-page="transaction-pending-list"]') -if ($transactionPendingListPage.length) { - initRedux(reducer, { - main (store) { - store.dispatch({ - type: 'PAGE_LOAD', - pendingTransactionCount: $('[data-selector="transaction-pending-count"]').text(), - beyondPageOne: !!humps.camelizeKeys(URI(window.location).query(true)).insertedAt - }) - const transactionsChannel = socket.channel(`transactions:new_transaction`) - transactionsChannel.join() - transactionsChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) - transactionsChannel.on('transaction', (msg) => - store.dispatch({ type: 'RECEIVED_NEW_TRANSACTION', msg: humps.camelizeKeys(msg) }) - ) - const pendingTransactionsChannel = socket.channel(`transactions:new_pending_transaction`) - pendingTransactionsChannel.join() - pendingTransactionsChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) - pendingTransactionsChannel.on('pending_transaction', batchChannel((msgs) => - store.dispatch({ type: 'RECEIVED_NEW_PENDING_TRANSACTION_BATCH', msgs: humps.camelizeKeys(msgs) })) - ) - }, - render (state, oldState) { - const $channelBatching = $('[data-selector="channel-batching-message"]') - const $channelBatchingCount = $('[data-selector="channel-batching-count"]') - const $channelDisconnected = $('[data-selector="channel-disconnected-message"]') - const $pendingTransactionsList = $('[data-selector="transactions-pending-list"]') - const $pendingTransactionsCount = $('[data-selector="transaction-pending-count"]') - - if (state.channelDisconnected) $channelDisconnected.show() - if (oldState.pendingTransactionCount !== state.pendingTransactionCount) { - $pendingTransactionsCount.empty().append(numeral(state.pendingTransactionCount).format()) - } - if (oldState.newTransactionHashes !== state.newTransactionHashes && state.newTransactionHashes.length > 0) { - const $transaction = $(`[data-transaction-hash="${state.newTransactionHashes[0]}"]`) - $transaction.addClass('shrink-out') - setTimeout(() => { - if ($transaction.length === 1 && $transaction.siblings().length === 0 && state.pendingTransactionCount > 0) { - window.location.href = URI(window.location).removeQuery('inserted_at').removeQuery('hash').toString() - } else { - slideUpRemove($transaction) - } - }, 400) - } - if (state.newPendingTransactionHashesBatch.length) { - $channelBatching.show() - $channelBatchingCount[0].innerHTML = numeral(state.newPendingTransactionHashesBatch.length).format() - } else { - $channelBatching.hide() - } - if (oldState.newPendingTransactions !== state.newPendingTransactions) { - const newTransactionsToInsert = state.newPendingTransactions.slice(oldState.newPendingTransactions.length) - slideDownPrepend($pendingTransactionsList, newTransactionsToInsert.reverse().join('')) - - updateAllAges() - } - } - }) -} - -const $transactionListPage = $('[data-page="transaction-list"]') -if ($transactionListPage.length) { - initRedux(reducer, { - main (store) { - store.dispatch({ - type: 'PAGE_LOAD', - transactionCount: $('[data-selector="transaction-count"]').text(), - beyondPageOne: !!humps.camelizeKeys(URI(window.location).query(true)).index - }) - const transactionsChannel = socket.channel(`transactions:new_transaction`) - transactionsChannel.join() - transactionsChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) - transactionsChannel.on('transaction', batchChannel((msgs) => - store.dispatch({ type: 'RECEIVED_NEW_TRANSACTION_BATCH', msgs: humps.camelizeKeys(msgs) })) - ) - }, - render (state, oldState) { - const $channelBatching = $('[data-selector="channel-batching-message"]') - const $channelBatchingCount = $('[data-selector="channel-batching-count"]') - const $channelDisconnected = $('[data-selector="channel-disconnected-message"]') - const $transactionsList = $('[data-selector="transactions-list"]') - const $transactionCount = $('[data-selector="transaction-count"]') - - if (state.channelDisconnected) $channelDisconnected.show() - if (oldState.transactionCount !== state.transactionCount) $transactionCount.empty().append(numeral(state.transactionCount).format()) - if (state.batchCountAccumulator) { - $channelBatching.show() - $channelBatchingCount[0].innerHTML = numeral(state.batchCountAccumulator).format() - } else { - $channelBatching.hide() - } - if (oldState.newTransactions !== state.newTransactions) { - const newTransactionsToInsert = state.newTransactions.slice(oldState.newTransactions.length) - slideDownPrepend($transactionsList, newTransactionsToInsert.reverse().join('')) - - updateAllAges() - } - } - }) -} diff --git a/apps/block_scout_web/assets/js/pages/transactions.js b/apps/block_scout_web/assets/js/pages/transactions.js new file mode 100644 index 0000000000..04544e83b4 --- /dev/null +++ b/apps/block_scout_web/assets/js/pages/transactions.js @@ -0,0 +1,100 @@ +import $ from 'jquery' +import _ from 'lodash' +import URI from 'urijs' +import humps from 'humps' +import numeral from 'numeral' +import socket from '../socket' +import { updateAllAges } from '../lib/from_now' +import { batchChannel, initRedux, slideDownPrepend } from '../utils' + +const BATCH_THRESHOLD = 10 + +export const initialState = { + batchCountAccumulator: 0, + beyondPageOne: null, + channelDisconnected: false, + newTransactions: [], + transactionCount: null +} + +export function reducer (state = initialState, action) { + switch (action.type) { + case 'PAGE_LOAD': { + return Object.assign({}, state, { + beyondPageOne: action.beyondPageOne, + transactionCount: numeral(action.transactionCount).value() + }) + } + case 'CHANNEL_DISCONNECTED': { + return Object.assign({}, state, { + channelDisconnected: true, + batchCountAccumulator: 0 + }) + } + case 'RECEIVED_NEW_TRANSACTION_BATCH': { + if (state.channelDisconnected) return state + + const transactionCount = state.transactionCount + action.msgs.length + + if (state.beyondPageOne) return Object.assign({}, state, { transactionCount }) + + if (!state.batchCountAccumulator && action.msgs.length < BATCH_THRESHOLD) { + return Object.assign({}, state, { + newTransactions: [ + ...state.newTransactions, + ..._.map(action.msgs, 'transactionHtml') + ], + transactionCount + }) + } else { + return Object.assign({}, state, { + batchCountAccumulator: state.batchCountAccumulator + action.msgs.length, + transactionCount + }) + } + } + default: + return state + } +} + +const $transactionListPage = $('[data-page="transaction-list"]') +if ($transactionListPage.length) { + initRedux(reducer, { + main (store) { + store.dispatch({ + type: 'PAGE_LOAD', + transactionCount: $('[data-selector="transaction-count"]').text(), + beyondPageOne: !!humps.camelizeKeys(URI(window.location).query(true)).index + }) + const transactionsChannel = socket.channel(`transactions:new_transaction`) + transactionsChannel.join() + transactionsChannel.onError(() => store.dispatch({ type: 'CHANNEL_DISCONNECTED' })) + transactionsChannel.on('transaction', batchChannel((msgs) => + store.dispatch({ type: 'RECEIVED_NEW_TRANSACTION_BATCH', msgs: humps.camelizeKeys(msgs) })) + ) + }, + render (state, oldState) { + const $channelBatching = $('[data-selector="channel-batching-message"]') + const $channelBatchingCount = $('[data-selector="channel-batching-count"]') + const $channelDisconnected = $('[data-selector="channel-disconnected-message"]') + const $transactionsList = $('[data-selector="transactions-list"]') + const $transactionCount = $('[data-selector="transaction-count"]') + + if (state.channelDisconnected) $channelDisconnected.show() + if (oldState.transactionCount !== state.transactionCount) $transactionCount.empty().append(numeral(state.transactionCount).format()) + if (state.batchCountAccumulator) { + $channelBatching.show() + $channelBatchingCount[0].innerHTML = numeral(state.batchCountAccumulator).format() + } else { + $channelBatching.hide() + } + if (oldState.newTransactions !== state.newTransactions) { + const newTransactionsToInsert = state.newTransactions.slice(oldState.newTransactions.length) + slideDownPrepend($transactionsList, newTransactionsToInsert.reverse().join('')) + + updateAllAges() + } + } + }) +} diff --git a/apps/block_scout_web/assets/js/utils.js b/apps/block_scout_web/assets/js/utils.js index ed69186d1c..28f36b3cfd 100644 --- a/apps/block_scout_web/assets/js/utils.js +++ b/apps/block_scout_web/assets/js/utils.js @@ -1,6 +1,8 @@ import $ from 'jquery' import _ from 'lodash' -import { createStore } from 'redux' +import { createStore as reduxCreateStore } from 'redux' +import morph from 'nanomorph' +import { updateAllAges } from './lib/from_now' export function batchChannel (func) { let msgs = [] @@ -40,6 +42,37 @@ export function initRedux (reducer, { main, render, debug } = {}) { if (main) main(store) } +export function createStore (reducer) { + return reduxCreateStore(reducer, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()) +} + +export function connectElements ({ elements, store }) { + function loadElements () { + return _.reduce(elements, (pageLoadParams, { load }, selector) => { + if (!load) return pageLoadParams + const $el = $(selector) + if (!$el.length) return pageLoadParams + const morePageLoadParams = load($el, store) + return _.isObject(morePageLoadParams) ? Object.assign(pageLoadParams, morePageLoadParams) : pageLoadParams + }, {}) + } + function renderElements (state, oldState) { + _.forIn(elements, ({ render }, selector) => { + if (!render) return + const $el = $(selector) + if (!$el.length) return + render($el, state, oldState) + }) + } + store.dispatch(Object.assign(loadElements(), { type: 'ELEMENTS_LOAD' })) + let oldState = store.getState() + store.subscribe(() => { + const state = store.getState() + renderElements(state, oldState) + oldState = state + }) +} + export function skippedBlockListBuilder (skippedBlockNumbers, newestBlock, oldestBlock) { for (let i = newestBlock - 1; i > oldestBlock; i--) skippedBlockNumbers.push(i) return skippedBlockNumbers @@ -52,6 +85,13 @@ export function slideDownPrepend ($container, content) { } }) } +export function slideDownAppend ($container, content) { + smarterSlideDown($(content), { + insert ($el) { + $container.append($el) + } + }) +} export function slideDownBefore ($container, content) { smarterSlideDown($(content), { insert ($el) { @@ -105,3 +145,71 @@ function smarterSlideUp ($el, { complete = _.noop } = {}) { $el.slideUp({ complete, easing: 'linear' }) } } + +// The goal of this function is to DOM diff lists, so upon completion `container.innerHTML` should be +// equivalent to `newElements.join('')`. +// +// We could simply do `container.innerHTML = newElements.join('')` but that would not be efficient and +// it not animate appropriately. We could also simply use `morph` (or a similar library) on the entire +// list, however that doesn't give us the proper amount of control for animations. +// +// This function will walk though, remove items currently in `container` which are not in the new list. +// Then it will swap the contents of the items that are in both lists in case the items were updated or +// the order changed. Finally, it will add elements to `container` which are in the new list and didn't +// already exist in the DOM. +// +// Params: +// container: the DOM element which contents need replaced +// newElements: a list of elements that need to be put into the container +// options: +// key: the path to the unique identifier of each element +// horizontal: our horizontal animations are handled in CSS, so passing in `true` will not play JS +// animations +export function listMorph (container, newElements, { key, horizontal } = {}) { + if (!container) return + const oldElements = $(container).children().get() + let currentList = _.map(oldElements, (el) => ({ id: _.get(el, key), el })) + const newList = _.map(newElements, (el) => ({ id: _.get(el, key), el })) + const overlap = _.intersectionBy(newList, currentList, 'id').map(({ id, el }) => ({ id, el: updateAllAges($(el))[0] })) + + // remove old items + const removals = _.differenceBy(currentList, newList, 'id') + let canAnimate = !horizontal && removals.length <= 1 + removals.forEach(({ el }) => { + if (!canAnimate) return el.remove() + const $el = $(el) + $el.addClass('shrink-out') + setTimeout(() => { slideUpRemove($el) }, 400) + }) + currentList = _.differenceBy(currentList, removals, 'id') + + // update kept items + currentList = currentList.map(({ el }, i) => ({ + id: overlap[i].id, + el: el.outerHTML === overlap[i].el.outerHTML ? el : morph(el, overlap[i].el) + })) + + // add new items + const finalList = newList.map(({ id, el }) => _.get(_.find(currentList, { id }), 'el', el)).reverse() + canAnimate = !horizontal + finalList.forEach((el, i) => { + if (el.parentElement) return + if (!canAnimate) return container.insertBefore(el, _.get(finalList, `[${i - 1}]`)) + canAnimate = false + if (!_.get(finalList, `[${i - 1}]`)) return slideDownAppend($(container), el) + slideDownBefore($(_.get(finalList, `[${i - 1}]`)), el) + }) +} + +export function onScrollBottom (callback) { + const $window = $(window) + function infiniteScrollChecker () { + const scrollHeight = $(document).height() + const scrollPosition = $window.height() + $window.scrollTop() + if ((scrollHeight - scrollPosition) / scrollHeight === 0) { + callback() + } + } + infiniteScrollChecker() + $window.on('scroll', infiniteScrollChecker) +} diff --git a/apps/block_scout_web/assets/package-lock.json b/apps/block_scout_web/assets/package-lock.json index c9c461305c..a9a49fb7c1 100644 --- a/apps/block_scout_web/assets/package-lock.json +++ b/apps/block_scout_web/assets/package-lock.json @@ -3789,8 +3789,7 @@ "version": "2.1.1", "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -3814,15 +3813,13 @@ "version": "1.0.0", "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3839,22 +3836,19 @@ "version": "1.1.0", "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3985,8 +3979,7 @@ "version": "2.0.3", "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -4000,7 +3993,6 @@ "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4017,7 +4009,6 @@ "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4026,15 +4017,13 @@ "version": "0.0.8", "resolved": false, "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "resolved": false, "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -4055,7 +4044,6 @@ "resolved": false, "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -4144,8 +4132,7 @@ "version": "1.0.1", "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -4159,7 +4146,6 @@ "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -4255,8 +4241,7 @@ "version": "5.1.1", "resolved": false, "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -4298,7 +4283,6 @@ "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4320,7 +4304,6 @@ "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4369,15 +4352,13 @@ "version": "1.0.2", "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.2", "resolved": false, "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", - "dev": true, - "optional": true + "dev": true } } }, @@ -6428,6 +6409,11 @@ "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", "dev": true }, + "nanoassert": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz", + "integrity": "sha1-TzFS4JVA/eKMdvRLGbvNHVpCR40=" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -6455,6 +6441,14 @@ } } }, + "nanomorph": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/nanomorph/-/nanomorph-5.1.3.tgz", + "integrity": "sha512-VydkKjFWU/DAO0R10awFASRNXQKHrZUMdMIiNcdmWm+IhuifuPOw/dDtpiQ1cNROF8f3ATPrcKRVarEayQJOqA==", + "requires": { + "nanoassert": "^1.1.0" + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", diff --git a/apps/block_scout_web/assets/package.json b/apps/block_scout_web/assets/package.json index a3c05cba5a..be66f75088 100644 --- a/apps/block_scout_web/assets/package.json +++ b/apps/block_scout_web/assets/package.json @@ -28,6 +28,7 @@ "jquery": "^3.3.1", "lodash": "^4.17.10", "moment": "^2.22.1", + "nanomorph": "^5.1.3", "numeral": "^2.0.6", "path-parser": "^4.1.1", "phoenix": "file:../../../deps/phoenix", diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex index bb3511a1b3..08e4e5c321 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -16,7 +16,7 @@ defmodule BlockScoutWeb.Chain do alias Explorer.Chain.{ Address, - Address.TokenBalance, + Address.CurrentTokenBalance, Block, InternalTransaction, Log, @@ -198,7 +198,7 @@ defmodule BlockScoutWeb.Chain do %{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at_datetime} end - defp paging_params(%TokenBalance{address_hash: address_hash, value: value}) do + defp paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) do %{"address_hash" => to_string(address_hash), "value" => Decimal.to_integer(value)} end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex index 3c29ffa4f3..22825ae63a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex @@ -7,9 +7,10 @@ defmodule BlockScoutWeb.AddressController do def index(conn, _params) do render(conn, "index.html", - addresses: Chain.list_top_addresses(), + address_tx_count_pairs: Chain.list_top_addresses(), address_estimated_count: Chain.address_estimated_count(), - exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null() + exchange_rate: Market.get_exchange_rate(Explorer.coin()) || Token.null(), + total_supply: Chain.total_supply() ) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex index 3a797d2974..d33d54ccb8 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex @@ -8,34 +8,87 @@ defmodule BlockScoutWeb.AddressTransactionController do import BlockScoutWeb.AddressController, only: [transaction_count: 1, validation_count: 1] import BlockScoutWeb.Chain, only: [current_filter: 1, paging_options: 1, next_page_params: 3, split_list_by_page: 1] + alias BlockScoutWeb.TransactionView alias Explorer.{Chain, Market} + alias Explorer.Chain.Hash alias Explorer.ExchangeRates.Token + alias Phoenix.View + + @transaction_necessity_by_association [ + necessity_by_association: %{ + [created_contract_address: :names] => :optional, + [from_address: :names] => :optional, + [to_address: :names] => :optional, + :token_transfers => :optional + } + ] + + def index(conn, %{"address_id" => address_hash_string, "type" => "JSON"} = params) do + with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + {:ok, address} <- Chain.hash_to_address(address_hash) do + options = + @transaction_necessity_by_association + |> put_in([:necessity_by_association, :block], :required) + |> Keyword.merge(paging_options(params)) + |> Keyword.merge(current_filter(params)) + + {transactions, next_page} = get_transactions_and_next_page(address, options) + + next_page_url = + case next_page_params(next_page, transactions, params) do + nil -> + nil + + next_page_params -> + address_transaction_path( + conn, + :index, + address, + next_page_params + ) + end + + json( + conn, + %{ + transactions: + Enum.map(transactions, fn transaction -> + %{ + transaction_hash: Hash.to_string(transaction.hash), + transaction_html: + View.render_to_string( + TransactionView, + "_tile.html", + current_address: address, + transaction: transaction + ) + } + end), + next_page_url: next_page_url + } + ) + else + :error -> + unprocessable_entity(conn) + + {:error, :not_found} -> + not_found(conn) + end + end def index(conn, %{"address_id" => address_hash_string} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, address} <- Chain.hash_to_address(address_hash) do pending_options = - [ - necessity_by_association: %{ - [created_contract_address: :names] => :optional, - [from_address: :names] => :optional, - [to_address: :names] => :optional, - :token_transfers => :optional - } - ] - |> Keyword.merge(paging_options(params)) + @transaction_necessity_by_association + |> Keyword.merge(paging_options(%{})) |> Keyword.merge(current_filter(params)) full_options = put_in(pending_options, [:necessity_by_association, :block], :required) - transactions_plus_one = Chain.address_to_transactions(address, full_options) - {transactions, next_page} = split_list_by_page(transactions_plus_one) + {transactions, next_page} = get_transactions_and_next_page(address, full_options) - pending_transactions = - case Map.has_key?(params, "block_number") do - true -> [] - false -> Chain.address_to_pending_transactions(address, pending_options) - end + pending_transactions = Chain.address_to_pending_transactions(address, pending_options) render( conn, @@ -57,4 +110,9 @@ defmodule BlockScoutWeb.AddressTransactionController do not_found(conn) end end + + defp get_transactions_and_next_page(address, options) do + transactions_plus_one = Chain.address_to_transactions(address, options) + split_list_by_page(transactions_plus_one) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex index a167d92638..7b5104a458 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex @@ -3,6 +3,26 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do alias Explorer.Chain + def gettxinfo(conn, params) do + with {:txhash_param, {:ok, txhash_param}} <- fetch_txhash(params), + {:format, {:ok, transaction_hash}} <- to_transaction_hash(txhash_param), + {:transaction, {:ok, transaction}} <- transaction_from_hash(transaction_hash) do + max_block_number = max_block_number() + + logs = Chain.transaction_to_logs(transaction) + render(conn, :gettxinfo, %{transaction: transaction, max_block_number: max_block_number, logs: logs}) + else + {:transaction, :error} -> + render(conn, :error, error: "Transaction not found") + + {:txhash_param, :error} -> + render(conn, :error, error: "Query parameter txhash is required") + + {:format, :error} -> + render(conn, :error, error: "Invalid txhash format") + end + end + def gettxreceiptstatus(conn, params) do with {:txhash_param, {:ok, txhash_param}} <- fetch_txhash(params), {:format, {:ok, transaction_hash}} <- to_transaction_hash(txhash_param) do @@ -35,6 +55,13 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do {:txhash_param, Map.fetch(params, "txhash")} end + defp transaction_from_hash(transaction_hash) do + case Chain.hash_to_transaction(transaction_hash, necessity_by_association: %{block: :required}) do + {:error, :not_found} -> {:transaction, :error} + {:ok, transaction} -> {:transaction, {:ok, transaction}} + end + end + defp to_transaction_hash(transaction_hash_string) do {:format, Chain.string_to_transaction_hash(transaction_hash_string)} end @@ -54,4 +81,11 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do _ -> "" end end + + defp max_block_number do + case Chain.max_block_number() do + {:ok, number} -> number + {:error, :not_found} -> 0 + end + end end diff --git a/apps/block_scout_web/lib/block_scout_web/etherscan.ex b/apps/block_scout_web/lib/block_scout_web/etherscan.ex index 033be85e52..4bb6a624d2 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -321,6 +321,30 @@ defmodule BlockScoutWeb.Etherscan do "result" => nil } + @transaction_gettxinfo_example_value %{ + "status" => "1", + "result" => %{ + "blockNumber" => "3", + "confirmations" => "0", + "from" => "0x000000000000000000000000000000000000000c", + "gasLimit" => "91966", + "gasUsed" => "95123", + "hash" => "0x0000000000000000000000000000000000000000000000000000000000000004", + "input" => "0x04", + "logs" => [ + %{ + "address" => "0x000000000000000000000000000000000000000e", + "data" => "0x00", + "topics" => ["First Topic", "Second Topic", "Third Topic", "Fourth Topic"] + } + ], + "success" => true, + "timeStamp" => "1541018182", + "to" => "0x000000000000000000000000000000000000000d", + "value" => "67612" + } + } + @transaction_gettxreceiptstatus_example_value %{ "status" => "1", "message" => "OK", @@ -428,6 +452,28 @@ defmodule BlockScoutWeb.Etherscan do example: ~s("18") } + @logs_details %{ + name: "Log Detail", + fields: %{ + address: @address_hash_type, + topics: %{ + type: "topics", + definition: "An array including the topics for the log.", + example: ~s(["0xf63780e752c6a54a94fc52715dbc5518a3b4c3c2833d301a204226548a2a8545"]) + }, + data: %{ + type: "data", + definition: "Non-indexed log parameters.", + example: ~s("0x") + }, + blockNumber: %{ + type: "block number", + definition: "A nonnegative number used to identify blocks.", + example: ~s("0x5c958") + } + } + } + @address_balance %{ name: "AddressBalance", fields: %{ @@ -735,6 +781,35 @@ defmodule BlockScoutWeb.Etherscan do } } + @transaction_info_model %{ + name: "TransactionInfo", + fields: %{ + hash: @transaction_hash_type, + timeStamp: %{ + type: "timestamp", + definition: "The transaction's block-timestamp.", + example: ~s("1439232889") + }, + blockNumber: @block_number_type, + confirmations: @confirmation_type, + success: %{ + type: "boolean", + definition: "Flag for success during tx execution", + example: ~s(true) + }, + from: @address_hash_type, + to: @address_hash_type, + value: @wei_type, + input: @input_type, + gasLimit: @wei_type, + gasUsed: @gas_type, + logs: %{ + type: "array", + array_type: @logs_details + } + } + } + @transaction_status_model %{ name: "TransactionStatus", fields: %{ @@ -1569,6 +1644,43 @@ defmodule BlockScoutWeb.Etherscan do ] } + @transaction_gettxinfo_action %{ + name: "gettxinfo", + description: "Get transaction info.", + required_params: [ + %{ + key: "txhash", + placeholder: "transactionHash", + type: "string", + description: "Transaction hash. Hash of contents of the transaction." + } + ], + optional_params: [], + responses: [ + %{ + code: "200", + description: "successful operation", + example_value: Jason.encode!(@transaction_gettxinfo_example_value), + model: %{ + name: "Result", + fields: %{ + status: @status_type, + message: @message_type, + result: %{ + type: "model", + model: @transaction_info_model + } + } + } + }, + %{ + code: "200", + description: "error", + example_value: Jason.encode!(@transaction_gettxreceiptstatus_example_value_error) + } + ] + } + @transaction_gettxreceiptstatus_action %{ name: "gettxreceiptstatus", description: "Get transaction receipt status.", @@ -1692,6 +1804,7 @@ defmodule BlockScoutWeb.Etherscan do @transaction_module %{ name: "transaction", actions: [ + @transaction_gettxinfo_action, @transaction_gettxreceiptstatus_action, @transaction_getstatus_action ] diff --git a/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex b/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex new file mode 100644 index 0000000000..f731f1c7cd --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/resolvers/address.ex @@ -0,0 +1,12 @@ +defmodule BlockScoutWeb.Resolvers.Address do + @moduledoc false + + alias Explorer.Chain + + def get_by(_, %{hashes: hashes}, _) do + case Chain.hashes_to_addresses(hashes) do + [] -> {:error, "Addresses not found."} + result -> {:ok, result} + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex index b249f03174..dcf9717329 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -38,12 +38,18 @@ defmodule BlockScoutWeb.Router do }) end - forward("/graphql", Absinthe.Plug, schema: BlockScoutWeb.Schema) + forward("/graphql", Absinthe.Plug, + schema: BlockScoutWeb.Schema, + analyze_complexity: true, + max_complexity: 50 + ) forward("/graphiql", Absinthe.Plug.GraphiQL, schema: BlockScoutWeb.Schema, interface: :playground, - socket: BlockScoutWeb.UserSocket + socket: BlockScoutWeb.UserSocket, + analyze_complexity: true, + max_complexity: 50 ) scope "/", BlockScoutWeb do diff --git a/apps/block_scout_web/lib/block_scout_web/schema.ex b/apps/block_scout_web/lib/block_scout_web/schema.ex index a50d29f3b9..03b2ee7c8e 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema.ex @@ -3,11 +3,18 @@ defmodule BlockScoutWeb.Schema do use Absinthe.Schema - alias BlockScoutWeb.Resolvers.{Block, Transaction} + alias BlockScoutWeb.Resolvers.{Address, Block, Transaction} import_types(BlockScoutWeb.Schema.Types) query do + @desc "Gets addresses by address hash." + field :addresses, list_of(:address) do + arg(:hashes, non_null(list_of(non_null(:address_hash)))) + resolve(&Address.get_by/3) + complexity(fn %{hashes: hashes}, child_complexity -> length(hashes) * child_complexity end) + end + @desc "Gets a block by number." field :block, :block do arg(:number, non_null(:integer)) diff --git a/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex b/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex index 5f71182cab..de42a24512 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema/scalars.ex @@ -3,7 +3,7 @@ defmodule BlockScoutWeb.Schema.Scalars do use Absinthe.Schema.Notation - alias Explorer.Chain.{Hash, Wei} + alias Explorer.Chain.{Data, Hash, Wei} alias Explorer.Chain.Hash.{Address, Full, Nonce} @desc """ @@ -24,6 +24,23 @@ defmodule BlockScoutWeb.Schema.Scalars do serialize(&to_string/1) end + @desc """ + An unpadded hexadecimal number with 0 or more digits. Each pair of digits + maps directly to a byte in the underlying binary representation. When + interpreted as a number, it should be treated as big-endian. + """ + scalar :data do + parse(fn + %Absinthe.Blueprint.Input.String{value: value} -> + Data.cast(value) + + _ -> + :error + end) + + serialize(&to_string/1) + end + @desc """ A 32-byte [KECCAK-256](https://en.wikipedia.org/wiki/SHA-3) hash. """ diff --git a/apps/block_scout_web/lib/block_scout_web/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/schema/types.ex index 66da7a55d4..b88306585b 100644 --- a/apps/block_scout_web/lib/block_scout_web/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/schema/types.ex @@ -6,12 +6,23 @@ defmodule BlockScoutWeb.Schema.Types do import_types(Absinthe.Type.Custom) import_types(BlockScoutWeb.Schema.Scalars) + @desc """ + A stored representation of a Web3 address. + """ + object :address do + field(:hash, :address_hash) + field(:fetched_coin_balance, :wei) + field(:fetched_coin_balance_block_number, :integer) + field(:contract_code, :data) + end + @desc """ A package of data that contains zero or more transactions, the hash of the previous block ("parent"), and optionally other data. Because each block (except for the initial "genesis block") points to the previous block, the data structure that they form is called a "blockchain". """ object :block do + field(:hash, :full_hash) field(:consensus, :boolean) field(:difficulty, :decimal) field(:gas_limit, :decimal) diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/_tile.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/_tile.html.eex index b1003b4638..0a6ac3375c 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/_tile.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/_tile.html.eex @@ -15,7 +15,7 @@ - <%= transaction_count(@address) %> + <%= @tx_count %> <%= gettext "Transactions sent" %> <% if validator?(@address) do %> @@ -36,7 +36,7 @@ data-usd-exchange-rate="<%= @exchange_rate.usd_value %>"> - (<%= balance_percentage(@address) %>) + (<%= balance_percentage(@address, @total_supply) %>) diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/index.html.eex index 13db6de433..851aa9a0d7 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/index.html.eex @@ -9,9 +9,10 @@

- <%= for {address, index} <- Enum.with_index(@addresses, 1) do %> + <%= for {{address, tx_count}, index} <- Enum.with_index(@address_tx_count_pairs, 1) do %> <%= render "_tile.html", address: address, index: index, exchange_rate: @exchange_rate, + total_supply: @total_supply, tx_count: tx_count, validation_count: validation_count(address) %> <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_internal_transaction/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_internal_transaction/index.html.eex index 17e929e972..e866c4ae19 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_internal_transaction/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_internal_transaction/index.html.eex @@ -8,12 +8,12 @@
-
+
-
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_transaction/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_transaction/index.html.eex index 9dda749f2c..5842b7553b 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_transaction/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_transaction/index.html.eex @@ -9,7 +9,7 @@
-
+

<%= gettext "Transactions" %>

- <%= link to: "#pending-transactions", class: "d-inline-block mb-3", "data-toggle": "collapse" do %> + <%= link to: "#pending-transactions-container", class: "d-inline-block mb-3", "data-toggle": "collapse" do %> <%= gettext("Show") %> <%= gettext("Hide") %> <%= length(@pending_transactions) %> <%= gettext("Pending Transactions") %> <% end %> -
- <%= for pending_transaction <- @pending_transactions do %> - <%= render(BlockScoutWeb.TransactionView, "_tile.html", current_address: @address, transaction: pending_transaction) %> - <% end %> +
+
+ <%= for pending_transaction <- @pending_transactions do %> + <%= render(BlockScoutWeb.TransactionView, "_tile.html", current_address: @address, transaction: pending_transaction) %> + <% end %> +

@@ -72,6 +74,17 @@ <%= render(BlockScoutWeb.TransactionView, "_tile.html", current_address: @address, transaction: transaction) %> <% end %> + + + <% else %>
<%= gettext "There are no transactions for this address." %> @@ -82,6 +95,7 @@ <%= link( gettext("Older"), class: "button button-secondary button-sm float-right mt-3", + "data-selector": "next-page-button", to: address_transaction_path( @conn, :index, diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex index 5049bf91ff..f9028ba3e6 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_validation/index.html.eex @@ -99,7 +99,7 @@
-
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/api_docs/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/api_docs/index.html.eex index 6c9e707011..85cd28cd5d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/api_docs/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/api_docs/index.html.eex @@ -4,7 +4,7 @@

API Documentation

[ <%= gettext "Base URL:" %> <%= @conn.host %>/api ] -

<%= gettext "This API is provided for developers transitioning their applications from Etherscan to Explorer. It supports GET and POST requests." %>

+

<%= gettext "This API is provided for developers transitioning their applications from Etherscan to BlockScout. It supports GET and POST requests." %>

diff --git a/apps/block_scout_web/lib/block_scout_web/templates/internal_transaction/_tile.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/internal_transaction/_tile.html.eex index 9125caf9bf..9a0630e97d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/internal_transaction/_tile.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/internal_transaction/_tile.html.eex @@ -1,4 +1,4 @@ -
+
<%= gettext("Internal Transaction") %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex index 9dda45ecbe..dbe0afbb63 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/layout/_topnav.html.eex @@ -56,13 +56,25 @@ <%= gettext("Accounts") %> <% end %> -
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_function_response.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_function_response.html.eex index 31e629f771..c2f47607c3 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_function_response.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_function_response.html.eex @@ -5,6 +5,6 @@ [<%= for item <- @outputs do %> <%= if named_argument?(item) do %><%= item["name"] %> <% end %> -(<%= item["type"] %>) : <%= values(item["value"]) %><% end %>] +(<%= item["type"] %>) : <%= values(item["value"], item["type"]) %><% end %>]
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_functions.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_functions.html.eex index e5f193652a..3008b37cf8 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_functions.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/smart_contract/_functions.html.eex @@ -45,13 +45,13 @@
<%= output["value"] %> - + <%= gettext("WEI")%> <%= gettext("ETH")%>
<% else %> - <%= output["value"] %> + <%= values(output["value"], output["type"]) %> <% end %> <% end %> <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex index 42aa38165f..79d67edb40 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex @@ -9,17 +9,17 @@ <%= gettext("Validated Transactions") %>

-
+ -
+ - + <%= for transaction <- @transactions do %> <%= render BlockScoutWeb.TransactionView, "_tile.html", transaction: transaction %> diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_view.ex index 041d1e9aa7..a9fdfbc746 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/address_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/address_view.ex @@ -1,7 +1,7 @@ defmodule BlockScoutWeb.AddressView do use BlockScoutWeb, :view - import BlockScoutWeb.AddressController, only: [transaction_count: 1, validation_count: 1] + import BlockScoutWeb.AddressController, only: [validation_count: 1] alias BlockScoutWeb.LayoutView alias Explorer.Chain @@ -94,16 +94,20 @@ defmodule BlockScoutWeb.AddressView do format_wei_value(balance, :ether) end - def balance_percentage(%Address{fetched_coin_balance: balance}) do + def balance_percentage(%Address{fetched_coin_balance: balance}, total_supply) do balance |> Wei.to(:ether) - |> Decimal.div(Decimal.new(Chain.total_supply())) + |> Decimal.div(Decimal.new(total_supply)) |> Decimal.mult(100) |> Decimal.round(4) |> Decimal.to_string(:normal) |> Kernel.<>("% #{gettext("Market Cap")}") end + def balance_percentage(%Address{fetched_coin_balance: _} = address) do + balance_percentage(address, Chain.total_supply()) + end + def balance_block_number(%Address{fetched_coin_balance_block_number: nil}), do: "" def balance_block_number(%Address{fetched_coin_balance_block_number: fetched_coin_balance_block_number}) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex index 6c19ac24c0..73dae614e6 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex @@ -3,6 +3,11 @@ defmodule BlockScoutWeb.API.RPC.TransactionView do alias BlockScoutWeb.API.RPC.RPCView + def render("gettxinfo.json", %{transaction: transaction, max_block_number: max_block_number, logs: logs}) do + data = prepare_transaction(transaction, max_block_number, logs) + RPCView.render("show.json", data: data) + end + def render("gettxreceiptstatus.json", %{status: status}) do prepared_status = prepare_tx_receipt_status(status) RPCView.render("show.json", data: %{"status" => prepared_status}) @@ -44,4 +49,33 @@ defmodule BlockScoutWeb.API.RPC.TransactionView do "errDescription" => error |> Atom.to_string() |> String.replace("_", " ") } end + + defp prepare_transaction(transaction, max_block_number, logs) do + %{ + "hash" => "#{transaction.hash}", + "timeStamp" => "#{DateTime.to_unix(transaction.block.timestamp)}", + "blockNumber" => "#{transaction.block_number}", + "confirmations" => "#{max_block_number - transaction.block_number}", + "success" => if(transaction.status == :ok, do: true, else: false), + "from" => "#{transaction.from_address_hash}", + "to" => "#{transaction.to_address_hash}", + "value" => "#{transaction.value.value}", + "input" => "#{transaction.input}", + "gasLimit" => "#{transaction.gas}", + "gasUsed" => "#{transaction.gas_used}", + "logs" => Enum.map(logs, &prepare_log/1) + } + end + + defp prepare_log(log) do + %{ + "address" => "#{log.address_hash}", + "topics" => get_topics(log), + "data" => "#{log.data}" + } + end + + defp get_topics(log) do + [log.first_topic, log.second_topic, log.third_topic, log.fourth_topic] + end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex index a63d0ef0c9..c96c5efe91 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex @@ -3,13 +3,24 @@ defmodule BlockScoutWeb.SmartContractView do def queryable?(inputs), do: Enum.any?(inputs) - def address?(type), do: type == "address" + def address?(type), do: type in ["address", "address payable"] def named_argument?(%{"name" => ""}), do: false def named_argument?(%{"name" => nil}), do: false def named_argument?(%{"name" => _}), do: true def named_argument?(_), do: false - def values(values) when is_list(values), do: Enum.join(values, ",") - def values(value), do: value + def values(addresses, type) when type == "address[]" do + addresses + |> Enum.map(&values(&1, "address")) + |> Enum.join(", ") + end + + def values(value, type) when type in ["address", "address payable"] do + {:ok, address} = Explorer.Chain.Hash.Address.cast(value) + to_string(address) + end + + def values(values, _) when is_list(values), do: Enum.join(values, ",") + def values(value, _), do: value end diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index 5d48235874..08c9f33964 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -83,11 +83,6 @@ msgstr "" msgid "A string with the name of the module to be invoked." msgstr "" -#, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:64 -msgid "API" -msgstr "" - #, elixir-format #: lib/block_scout_web/templates/api_docs/_metatags.html.eex:4 msgid "API endpoints for the %{subnetwork}" @@ -233,7 +228,7 @@ msgstr "" #: lib/block_scout_web/templates/address_validation/index.html.eex:90 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:119 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:141 -#: lib/block_scout_web/views/address_view.ex:210 +#: lib/block_scout_web/views/address_view.ex:214 msgid "Code" msgstr "" @@ -514,7 +509,7 @@ msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:14 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:43 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:10 -#: lib/block_scout_web/views/address_view.ex:209 +#: lib/block_scout_web/views/address_view.ex:213 #: lib/block_scout_web/views/transaction_view.ex:176 msgid "Internal Transactions" msgstr "" @@ -639,7 +634,7 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:72 -#: lib/block_scout_web/templates/address_transaction/index.html.eex:83 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:96 #: lib/block_scout_web/templates/address_validation/index.html.eex:117 #: lib/block_scout_web/templates/block/index.html.eex:20 #: lib/block_scout_web/templates/block_transaction/index.html.eex:50 @@ -661,12 +656,12 @@ msgid "Owner Address" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:95 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:107 msgid "POA Core" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:94 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:106 msgid "POA Sokol" msgstr "" @@ -731,7 +726,7 @@ msgstr "" #: lib/block_scout_web/templates/address_validation/index.html.eex:53 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:33 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:75 -#: lib/block_scout_web/views/address_view.ex:211 +#: lib/block_scout_web/views/address_view.ex:215 #: lib/block_scout_web/views/tokens/overview_view.ex:37 msgid "Read Contract" msgstr "" @@ -757,13 +752,13 @@ msgid "Responses" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:71 -#: lib/block_scout_web/templates/layout/_topnav.html.eex:78 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:83 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:90 msgid "Search" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:71 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:83 msgid "Search by address, transaction hash, or block number" msgstr "" @@ -858,7 +853,7 @@ msgid "There are no tokens." msgstr "" #, elixir-format -#: lib/block_scout_web/templates/address_transaction/index.html.eex:77 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:90 msgid "There are no transactions for this address." msgstr "" @@ -872,11 +867,6 @@ msgstr "" msgid "There are no transfers for this Token." msgstr "" -#, elixir-format -#: lib/block_scout_web/templates/api_docs/index.html.eex:7 -msgid "This API is provided for developers transitioning their applications from Etherscan to Explorer. It supports GET and POST requests." -msgstr "" - #, elixir-format #: lib/block_scout_web/templates/transaction/overview.html.eex:23 msgid "This transaction is pending confirmation." @@ -940,7 +930,7 @@ msgstr "" #: lib/block_scout_web/templates/address_validation/index.html.eex:18 #: lib/block_scout_web/templates/address_validation/index.html.eex:62 #: lib/block_scout_web/templates/address_validation/index.html.eex:70 -#: lib/block_scout_web/views/address_view.ex:207 +#: lib/block_scout_web/views/address_view.ex:211 msgid "Tokens" msgstr "" @@ -1001,7 +991,7 @@ msgstr "" #: lib/block_scout_web/templates/block_transaction/index.html.eex:35 #: lib/block_scout_web/templates/chain/show.html.eex:71 #: lib/block_scout_web/templates/layout/_topnav.html.eex:35 -#: lib/block_scout_web/views/address_view.ex:208 +#: lib/block_scout_web/views/address_view.ex:212 msgid "Transactions" msgstr "" diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index 8a09bbec4f..35a2659ad7 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -83,11 +83,6 @@ msgstr "" msgid "A string with the name of the module to be invoked." msgstr "" -#, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:64 -msgid "API" -msgstr "" - #, elixir-format #: lib/block_scout_web/templates/api_docs/_metatags.html.eex:4 msgid "API endpoints for the %{subnetwork}" @@ -233,7 +228,7 @@ msgstr "" #: lib/block_scout_web/templates/address_validation/index.html.eex:90 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:119 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:141 -#: lib/block_scout_web/views/address_view.ex:210 +#: lib/block_scout_web/views/address_view.ex:214 msgid "Code" msgstr "" @@ -514,7 +509,7 @@ msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:14 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:43 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:10 -#: lib/block_scout_web/views/address_view.ex:209 +#: lib/block_scout_web/views/address_view.ex:213 #: lib/block_scout_web/views/transaction_view.ex:176 msgid "Internal Transactions" msgstr "" @@ -639,7 +634,7 @@ msgstr "" #, elixir-format #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:72 -#: lib/block_scout_web/templates/address_transaction/index.html.eex:83 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:96 #: lib/block_scout_web/templates/address_validation/index.html.eex:117 #: lib/block_scout_web/templates/block/index.html.eex:20 #: lib/block_scout_web/templates/block_transaction/index.html.eex:50 @@ -661,12 +656,12 @@ msgid "Owner Address" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:95 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:107 msgid "POA Core" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:94 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:106 msgid "POA Sokol" msgstr "" @@ -731,7 +726,7 @@ msgstr "" #: lib/block_scout_web/templates/address_validation/index.html.eex:53 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:33 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:75 -#: lib/block_scout_web/views/address_view.ex:211 +#: lib/block_scout_web/views/address_view.ex:215 #: lib/block_scout_web/views/tokens/overview_view.ex:37 msgid "Read Contract" msgstr "" @@ -757,13 +752,13 @@ msgid "Responses" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:71 -#: lib/block_scout_web/templates/layout/_topnav.html.eex:78 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:83 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:90 msgid "Search" msgstr "" #, elixir-format -#: lib/block_scout_web/templates/layout/_topnav.html.eex:71 +#: lib/block_scout_web/templates/layout/_topnav.html.eex:83 msgid "Search by address, transaction hash, or block number" msgstr "" @@ -858,7 +853,7 @@ msgid "There are no tokens." msgstr "" #, elixir-format -#: lib/block_scout_web/templates/address_transaction/index.html.eex:77 +#: lib/block_scout_web/templates/address_transaction/index.html.eex:90 msgid "There are no transactions for this address." msgstr "" @@ -872,11 +867,6 @@ msgstr "" msgid "There are no transfers for this Token." msgstr "" -#, elixir-format -#: lib/block_scout_web/templates/api_docs/index.html.eex:7 -msgid "This API is provided for developers transitioning their applications from Etherscan to Explorer. It supports GET and POST requests." -msgstr "" - #, elixir-format #: lib/block_scout_web/templates/transaction/overview.html.eex:23 msgid "This transaction is pending confirmation." @@ -940,7 +930,7 @@ msgstr "" #: lib/block_scout_web/templates/address_validation/index.html.eex:18 #: lib/block_scout_web/templates/address_validation/index.html.eex:62 #: lib/block_scout_web/templates/address_validation/index.html.eex:70 -#: lib/block_scout_web/views/address_view.ex:207 +#: lib/block_scout_web/views/address_view.ex:211 msgid "Tokens" msgstr "" @@ -1001,7 +991,7 @@ msgstr "" #: lib/block_scout_web/templates/block_transaction/index.html.eex:35 #: lib/block_scout_web/templates/chain/show.html.eex:71 #: lib/block_scout_web/templates/layout/_topnav.html.eex:35 -#: lib/block_scout_web/views/address_view.ex:208 +#: lib/block_scout_web/views/address_view.ex:212 msgid "Transactions" msgstr "" diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs index 1ad14d2952..071667cf77 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs @@ -10,7 +10,9 @@ defmodule BlockScoutWeb.AddressControllerTest do conn = get(conn, address_path(conn, :index)) - assert conn.assigns.addresses |> Enum.map(& &1.hash) == address_hashes + assert conn.assigns.address_tx_count_pairs + |> Enum.map(fn {address, _transaction_count} -> address end) + |> Enum.map(& &1.hash) == address_hashes end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs index 0c71e32503..39b9d76fa6 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs @@ -76,7 +76,7 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do 50 |> insert_list(:transaction, from_address: address) |> with_block() - |> Enum.map(& &1.hash) + |> Enum.map(&to_string(&1.hash)) %Transaction{block_number: block_number, index: index} = :transaction @@ -85,47 +85,21 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do conn = get(conn, address_transaction_path(BlockScoutWeb.Endpoint, :index, address.hash), %{ + "type" => "JSON", "block_number" => Integer.to_string(block_number), "index" => Integer.to_string(index) }) + {:ok, %{"transactions" => transactions}} = conn.resp_body |> Poison.decode() + actual_hashes = - conn.assigns.transactions - |> Enum.map(& &1.hash) + transactions + |> Enum.map(& &1["transaction_hash"]) |> Enum.reverse() assert second_page_hashes == actual_hashes end - test "does not return pending transactions if beyond page one", %{conn: conn} do - address = insert(:address) - - 50 - |> insert_list(:transaction, from_address: address) - |> with_block() - |> Enum.map(& &1.hash) - - %Transaction{block_number: block_number, index: index} = - :transaction - |> insert(from_address: address) - |> with_block() - - pending = insert(:transaction, from_address: address, to_address: address) - - conn = - get(conn, address_transaction_path(BlockScoutWeb.Endpoint, :index, address.hash), %{ - "block_number" => Integer.to_string(block_number), - "index" => Integer.to_string(index) - }) - - actual_pending_hashes = - conn.assigns.pending_transactions - |> Enum.map(& &1.hash) - |> Enum.reverse() - - refute Enum.member?(actual_pending_hashes, pending.hash) - end - test "next_page_params exist if not on last page", %{conn: conn} do address = insert(:address) block = %Block{number: number} = insert(:block) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs index 008bb64300..bc0cfcca96 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs @@ -310,4 +310,114 @@ defmodule BlockScoutWeb.API.RPC.TransactionControllerTest do assert response["message"] == "OK" end end + + describe "gettxinfo" do + test "with missing txhash", %{conn: conn} do + params = %{ + "module" => "transaction", + "action" => "gettxinfo" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "txhash is required" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with an invalid txhash", %{conn: conn} do + params = %{ + "module" => "transaction", + "action" => "gettxinfo", + "txhash" => "badhash" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid txhash format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with a txhash that doesn't exist", %{conn: conn} do + params = %{ + "module" => "transaction", + "action" => "gettxinfo", + "txhash" => "0x40eb908387324f2b575b4879cd9d7188f69c8fc9d87c901b9e2daaea4b442170" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Transaction not found" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "with a txhash with ok status", %{conn: conn} do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block, status: :ok) + + address = insert(:address) + + log = + insert(:log, + address: address, + transaction: transaction, + first_topic: "first topic", + second_topic: "second topic" + ) + + params = %{ + "module" => "transaction", + "action" => "gettxinfo", + "txhash" => "#{transaction.hash}" + } + + expected_result = %{ + "hash" => "#{transaction.hash}", + "timeStamp" => "#{DateTime.to_unix(transaction.block.timestamp)}", + "blockNumber" => "#{transaction.block_number}", + "confirmations" => "0", + "success" => true, + "from" => "#{transaction.from_address_hash}", + "to" => "#{transaction.to_address_hash}", + "value" => "#{transaction.value.value}", + "input" => "#{transaction.input}", + "gasLimit" => "#{transaction.gas}", + "gasUsed" => "#{transaction.gas_used}", + "logs" => [ + %{ + "address" => "#{address}", + "data" => "#{log.data}", + "topics" => ["first topic", "second topic", nil, nil] + } + ] + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs index 6ce8d7915b..66c8ce9de4 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs @@ -22,7 +22,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do insert_list( 2, - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash ) @@ -43,7 +43,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do 1..50 |> Enum.map( &insert( - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: &1 + 1000 ) @@ -52,7 +52,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do token_balance = insert( - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: 50000 ) @@ -78,7 +78,7 @@ defmodule BlockScoutWeb.Tokens.HolderControllerTest do Enum.each( 1..51, &insert( - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash, value: &1 + 1000 ) diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs index 8a5d769e62..06f1255964 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs @@ -9,7 +9,7 @@ defmodule BlockScoutWeb.ViewingTokensTest do insert_list( 2, - :token_balance, + :address_current_token_balance, token_contract_address_hash: token.contract_address_hash ) diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs new file mode 100644 index 0000000000..4854cf4738 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/schema/query/address_test.exs @@ -0,0 +1,142 @@ +defmodule BlockScoutWeb.Schema.Query.AddressTest do + use BlockScoutWeb.ConnCase + + describe "address field" do + test "with valid argument 'hashes', returns all expected fields", %{conn: conn} do + address = insert(:address, fetched_coin_balance: 100) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + hash + fetched_coin_balance + fetched_coin_balance_block_number + contract_code + } + } + """ + + variables = %{"hashes" => to_string(address.hash)} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "addresses" => [ + %{ + "hash" => to_string(address.hash), + "fetched_coin_balance" => to_string(address.fetched_coin_balance.value), + "fetched_coin_balance_block_number" => address.fetched_coin_balance_block_number, + "contract_code" => nil + } + ] + } + } + end + + test "with contract address, `contract_code` is serialized as expected", %{conn: conn} do + address = insert(:contract_address, fetched_coin_balance: 100) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + contract_code + } + } + """ + + variables = %{"hashes" => to_string(address.hash)} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert json_response(conn, 200) == %{ + "data" => %{ + "addresses" => [ + %{ + "contract_code" => to_string(address.contract_code) + } + ] + } + } + end + + test "errors for non-existent address hashes", %{conn: conn} do + address = build(:address) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + fetched_coin_balance + } + } + """ + + variables = %{"hashes" => [to_string(address.hash)]} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ ~s(Addresses not found.) + end + + test "errors if argument 'hashes' is missing", %{conn: conn} do + query = """ + query { + addresses { + fetched_coin_balance + } + } + """ + + variables = %{} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] == ~s(In argument "hashes": Expected type "[AddressHash!]!", found null.) + end + + test "errors if argument 'hashes' is not a list of address hashes", %{conn: conn} do + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + fetched_coin_balance + } + } + """ + + variables = %{"hashes" => ["someInvalidHash"]} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error]} = json_response(conn, 200) + assert error["message"] =~ ~s(Argument "hashes" has invalid value) + end + + test "correlates complexity to size of 'hashes' argument", %{conn: conn} do + # max of 12 addresses with four fields of complexity 1 can be fetched + # per query: + # 12 * 4 = 48, which is less than a max complexity of 50 + hashes = 13 |> build_list(:address) |> Enum.map(&to_string(&1.hash)) + + query = """ + query ($hashes: [AddressHash!]!) { + addresses(hashes: $hashes) { + hash + fetched_coin_balance + fetched_coin_balance_block_number + contract_code + } + } + """ + + variables = %{"hashes" => hashes} + + conn = get(conn, "/graphql", query: query, variables: variables) + + assert %{"errors" => [error1, error2]} = json_response(conn, 200) + assert error1["message"] =~ ~s(Field addresses is too complex) + assert error2["message"] =~ ~s(Operation is too complex) + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs b/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs index a635614f27..432c5ebf2a 100644 --- a/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs +++ b/apps/block_scout_web/test/block_scout_web/schema/query/block_test.exs @@ -8,6 +8,7 @@ defmodule BlockScoutWeb.Schema.Query.BlockTest do query = """ query ($number: Int!) { block(number: $number) { + hash consensus difficulty gas_limit @@ -31,6 +32,7 @@ defmodule BlockScoutWeb.Schema.Query.BlockTest do assert json_response(conn, 200) == %{ "data" => %{ "block" => %{ + "hash" => to_string(block.hash), "consensus" => block.consensus, "difficulty" => to_string(block.difficulty), "gas_limit" => to_string(block.gas_limit), diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs index 281ab779dc..5ab8133bf5 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs @@ -24,6 +24,12 @@ defmodule BlockScoutWeb.SmartContractViewTest do assert SmartContractView.address?(type) end + test "returns true when the type is equal to the string 'address payable'" do + type = "address payable" + + assert SmartContractView.address?(type) + end + test "returns false when the type is not equal the string 'address'" do type = "name" @@ -57,17 +63,37 @@ defmodule BlockScoutWeb.SmartContractViewTest do end end - describe "values/1" do - test "joins the values when it is a list" do + describe "values/2" do + test "joins the values when it is a list of a given type" do values = [8, 6, 9, 2, 2, 37] - assert SmartContractView.values(values) == "8,6,9,2,2,37" + assert SmartContractView.values(values, "type") == "8,6,9,2,2,37" + end + + test "convert the value to string receiving a value and the 'address' type" do + value = <<95, 38, 9, 115, 52, 182, 163, 43, 121, 81, 223, 97, 253, 12, 88, 3, 236, 93, 131, 84>> + assert SmartContractView.values(value, "address") == "0x5f26097334b6a32b7951df61fd0c5803ec5d8354" + end + + test "convert the value to string receiving a value and the 'address payable' type" do + value = <<95, 38, 9, 115, 52, 182, 163, 43, 121, 81, 223, 97, 253, 12, 88, 3, 236, 93, 131, 84>> + assert SmartContractView.values(value, "address payable") == "0x5f26097334b6a32b7951df61fd0c5803ec5d8354" + end + + test "convert each value to string and join them when receiving 'address[]' as the type" do + value = [ + <<95, 38, 9, 115, 52, 182, 163, 43, 121, 81, 223, 97, 253, 12, 88, 3, 236, 93, 131, 84>>, + <<207, 38, 14, 163, 23, 85, 86, 55, 197, 95, 112, 229, 93, 186, 141, 90, 216, 65, 76, 176>> + ] + + assert SmartContractView.values(value, "address[]") == + "0x5f26097334b6a32b7951df61fd0c5803ec5d8354, 0xcf260ea317555637c55f70e55dba8d5ad8414cb0" end - test "returns the value" do + test "returns the value when the type is neither 'address' nor 'address payable'" do value = "POA" - assert SmartContractView.values(value) == "POA" + assert SmartContractView.values(value, "not address") == "POA" end end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex index ebade1d4da..b202d8a64d 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex @@ -11,16 +11,23 @@ defmodule EthereumJSONRPC.Block do @type elixir :: %{String.t() => non_neg_integer | DateTime.t() | String.t() | nil} @type params :: %{ difficulty: pos_integer(), + extra_data: EthereumJSONRPC.hash(), gas_limit: non_neg_integer(), gas_used: non_neg_integer(), hash: EthereumJSONRPC.hash(), + logs_bloom: EthereumJSONRPC.hash(), miner_hash: EthereumJSONRPC.hash(), + mix_hash: EthereumJSONRPC.hash(), nonce: EthereumJSONRPC.hash(), number: non_neg_integer(), parent_hash: EthereumJSONRPC.hash(), + receipts_root: EthereumJSONRPC.hash(), + sha3_uncles: EthereumJSONRPC.hash(), size: non_neg_integer(), + state_root: EthereumJSONRPC.hash(), timestamp: DateTime.t(), total_difficulty: non_neg_integer(), + transactions_root: EthereumJSONRPC.hash(), uncles: [EthereumJSONRPC.hash()] } @@ -95,16 +102,23 @@ defmodule EthereumJSONRPC.Block do ...> ) %{ difficulty: 340282366920938463463374607431465537093, + extra_data: "0xd5830108048650617269747986312e32322e31826c69", gas_limit: 6706541, gas_used: 0, hash: "0x52c867bc0a91e573dc39300143c3bead7408d09d45bdb686749f02684ece72f3", + logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", miner_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + mix_hash: "0x0", nonce: 0, number: 1, parent_hash: "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", + receipts_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", size: 576, + state_root: "0xc196ad59d867542ef20b29df5f418d07dc7234f4bc3d25260526620b7958a8fb", timestamp: Timex.parse!("2017-12-15T21:03:30Z", "{ISO:Extended:Z}"), total_difficulty: 340282366920938463463374607431465668165, + transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", uncles: [] } @@ -136,16 +150,23 @@ defmodule EthereumJSONRPC.Block do ...> ) %{ difficulty: 17561410778, + extra_data: "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", gas_limit: 5000, gas_used: 0, hash: "0x4d9423080290a650eaf6db19c87c76dff83d1b4ab64aefe6e5c5aa2d1f4b6623", + logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + mix_hash: "0xbbb93d610b2b0296a59f18474ac3d6086a9902aa7ca4b9a306692f7c3d496fdf", miner_hash: "0xbb7b8287f3f0a933474a79eae42cbca977791171", nonce: 5539500215739777653, number: 59, parent_hash: "0xcd5b5c4cecd7f18a13fe974255badffd58e737dc67596d56bc01f063dd282e9e", + receipts_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", size: 542, + state_root: "0x6fd0a5d82ca77d9f38c3ebbde11b11d304a5fcf3854f291df64395ab38ed43ba", timestamp: Timex.parse!("2015-07-30T15:32:07Z", "{ISO:Extended:Z}"), total_difficulty: 1039309006117, + transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", uncles: [] } @@ -154,30 +175,43 @@ defmodule EthereumJSONRPC.Block do def elixir_to_params( %{ "difficulty" => difficulty, + "extraData" => extra_data, "gasLimit" => gas_limit, "gasUsed" => gas_used, "hash" => hash, + "logsBloom" => logs_bloom, "miner" => miner_hash, "number" => number, "parentHash" => parent_hash, + "receiptsRoot" => receipts_root, + "sha3Uncles" => sha3_uncles, "size" => size, + "stateRoot" => state_root, "timestamp" => timestamp, "totalDifficulty" => total_difficulty, + "transactionsRoot" => transactions_root, "uncles" => uncles } = elixir ) do %{ difficulty: difficulty, + extra_data: extra_data, gas_limit: gas_limit, gas_used: gas_used, hash: hash, + logs_bloom: logs_bloom, miner_hash: miner_hash, + mix_hash: Map.get(elixir, "mixHash", "0x0"), nonce: Map.get(elixir, "nonce", 0), number: number, parent_hash: parent_hash, + receipts_root: receipts_root, + sha3_uncles: sha3_uncles, size: size, + state_root: state_root, timestamp: timestamp, total_difficulty: total_difficulty, + transactions_root: transactions_root, uncles: uncles } end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex index a6964e17db..1eef103513 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex @@ -45,16 +45,23 @@ defmodule EthereumJSONRPC.Blocks do [ %{ difficulty: 131072, + extra_data: "0x", gas_limit: 6700000, gas_used: 0, hash: "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", + logs_bloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", miner_hash: "0x0000000000000000000000000000000000000000", + mix_hash: "0x0", nonce: 0, number: 0, parent_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + receipts_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", size: 533, + state_root: "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", timestamp: Timex.parse!("1970-01-01T00:00:00Z", "{ISO:Extended:Z}"), total_difficulty: 131072, + transactions_root: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", uncles: ["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311"] } ] diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs index 4eedbe93dd..d299491eca 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs @@ -10,15 +10,6 @@ defmodule EthereumJSONRPC.RequestCoordinatorTest do setup :set_mox_global setup :verify_on_exit! - defp sleep_time(timeouts) do - wait_per_timeout = - :ethereum_jsonrpc - |> Application.get_env(RequestCoordinator) - |> Keyword.fetch!(:wait_per_timeout) - - timeouts * wait_per_timeout - end - setup do table = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.RequestCoordinator)[:rolling_window_opts][:table] diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs index 11629769cb..986eeb362a 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs @@ -95,18 +95,6 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClientTest do end end - defp cowboy(0) do - dispatch = :cowboy_router.compile([{:_, [{"/websocket", EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler, []}]}]) - {:ok, _} = :cowboy.start_http(EthereumJSONRPC.WebSocket.Cowboy, 100, [], env: [dispatch: dispatch]) - :ranch.get_port(EthereumJSONRPC.WebSocket.Cowboy) - end - - defp cowboy(port) do - dispatch = :cowboy_router.compile([{:_, [{"/websocket", EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler, []}]}]) - {:ok, _} = :cowboy.start_http(EthereumJSONRPC.WebSocket.Cowboy, 100, [port: port], env: [dispatch: dispatch]) - port - end - defp example_state(_) do %{state: %WebSocketClient{url: "ws://example.com"}} end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs index fd3dc3766a..1c19a1fa97 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs @@ -207,10 +207,15 @@ defmodule EthereumJSONRPCTest do "gasLimit" => "0x0", "gasUsed" => "0x0", "hash" => block_hash, + "extraData" => "0x0", + "logsBloom" => "0x0", "miner" => "0x0", "number" => block_number, "parentHash" => "0x0", + "receiptsRoot" => "0x0", "size" => "0x0", + "sha3Uncles" => "0x0", + "stateRoot" => "0x0", "timestamp" => "0x0", "totalDifficulty" => "0x0", "transactions" => [ @@ -231,6 +236,7 @@ defmodule EthereumJSONRPCTest do "value" => "0x0" } ], + "transactionsRoot" => "0x0", "uncles" => [] } } @@ -364,16 +370,22 @@ defmodule EthereumJSONRPCTest do id: 0, result: %{ "difficulty" => "0x0", + "extraData" => "0x0", "gasLimit" => "0x0", "gasUsed" => "0x0", "hash" => "0x0", + "logsBloom" => "0x0", "miner" => "0x0", "number" => "0x0", "parentHash" => "0x0", + "receiptsRoot" => "0x0", + "sha3Uncles" => "0x0", "size" => "0x0", + "stateRoot" => "0x0", "timestamp" => "0x0", "totalDifficulty" => "0x0", "transactions" => [], + "transactionsRoot" => "0x0", "uncles" => [] }, jsonrpc: "2.0" diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 2140e36b14..8cfb97bfcf 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -23,6 +23,7 @@ defmodule Explorer.Chain do alias Explorer.Chain.{ Address, Address.CoinBalance, + Address.CurrentTokenBalance, Address.TokenBalance, Block, Data, @@ -919,13 +920,20 @@ defmodule Explorer.Chain do Lists the top 250 `t:Explorer.Chain.Address.t/0`'s' in descending order based on coin balance. """ - @spec list_top_addresses :: [Address.t()] + @spec list_top_addresses :: [{Address.t(), non_neg_integer()}] def list_top_addresses do - Address - |> limit(250) - |> order_by(desc: :fetched_coin_balance, asc: :hash) - |> where([address], address.fetched_coin_balance > ^0) - |> Repo.all() + query = + from(a in Address, + left_join: t in Transaction, + on: a.hash == t.from_address_hash, + where: a.fetched_coin_balance > ^0, + group_by: [a.hash, a.fetched_coin_balance], + order_by: [desc: a.fetched_coin_balance, asc: a.hash], + select: {a, fragment("coalesce(1 + max(?), 0)", t.nonce)}, + limit: 250 + ) + + Repo.all(query) end @doc """ @@ -2070,7 +2078,7 @@ defmodule Explorer.Chain do @spec fetch_token_holders_from_token_hash(Hash.Address.t(), [paging_options]) :: [TokenBalance.t()] def fetch_token_holders_from_token_hash(contract_address_hash, options) do contract_address_hash - |> TokenBalance.token_holders_ordered_by_value(options) + |> CurrentTokenBalance.token_holders_ordered_by_value(options) |> Repo.all() end diff --git a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex new file mode 100644 index 0000000000..21f20e1f8c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex @@ -0,0 +1,108 @@ +defmodule Explorer.Chain.Address.CurrentTokenBalance do + @moduledoc """ + Represents the current token balance from addresses according to the last block. + """ + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query, only: [from: 2, limit: 2, order_by: 3, preload: 2, where: 3] + + alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain.{Address, Block, Hash, Token} + + @default_paging_options %PagingOptions{page_size: 50} + + @typedoc """ + * `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. + * `address_hash` - The address hash foreign key. + * `token` - The `t:Explorer.Chain.Token/0` so that the address has the balance. + * `token_contract_address_hash` - The contract address hash foreign key. + * `block_number` - The block's number that the transfer took place. + * `value` - The value that's represents the balance. + """ + @type t :: %__MODULE__{ + address: %Ecto.Association.NotLoaded{} | Address.t(), + address_hash: Hash.Address.t(), + token: %Ecto.Association.NotLoaded{} | Token.t(), + token_contract_address_hash: Hash.Address, + block_number: Block.block_number(), + inserted_at: DateTime.t(), + updated_at: DateTime.t(), + value: Decimal.t() | nil + } + + schema "address_current_token_balances" do + field(:value, :decimal) + field(:block_number, :integer) + field(:value_fetched_at, :utc_datetime) + + belongs_to(:address, Address, foreign_key: :address_hash, references: :hash, type: Hash.Address) + + belongs_to( + :token, + Token, + foreign_key: :token_contract_address_hash, + references: :contract_address_hash, + type: Hash.Address + ) + + timestamps() + end + + @optional_fields ~w(value value_fetched_at)a + @required_fields ~w(address_hash block_number token_contract_address_hash)a + @allowed_fields @optional_fields ++ @required_fields + + @doc false + def changeset(%__MODULE__{} = token_balance, attrs) do + token_balance + |> cast(attrs, @allowed_fields) + |> validate_required(@required_fields) + |> foreign_key_constraint(:address_hash) + |> foreign_key_constraint(:token_contract_address_hash) + end + + {:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") + @burn_address_hash burn_address_hash + + @doc """ + Builds an `Ecto.Query` to fetch the token holders from the given token contract address hash. + + The Token Holders are the addresses that own a positive amount of the Token. So this query is + considering the following conditions: + + * The token balance from the last block. + * Balances greater than 0. + * Excluding the burn address (0x0000000000000000000000000000000000000000). + + """ + def token_holders_ordered_by_value(token_contract_address_hash, options \\ []) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + token_contract_address_hash + |> token_holders_query + |> preload(:address) + |> order_by([tb], desc: :value) + |> page_token_balances(paging_options) + |> limit(^paging_options.page_size) + end + + defp token_holders_query(token_contract_address_hash) do + from( + tb in __MODULE__, + where: tb.token_contract_address_hash == ^token_contract_address_hash, + where: tb.address_hash != ^@burn_address_hash, + where: tb.value > 0 + ) + end + + defp page_token_balances(query, %PagingOptions{key: nil}), do: query + + defp page_token_balances(query, %PagingOptions{key: {value, address_hash}}) do + where( + query, + [tb], + tb.value < ^value or (tb.value == ^value and tb.address_hash < ^address_hash) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/address/token_balance.ex b/apps/explorer/lib/explorer/chain/address/token_balance.ex index 2b1c4f64b8..383eb24ec2 100644 --- a/apps/explorer/lib/explorer/chain/address/token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/token_balance.ex @@ -5,14 +5,12 @@ defmodule Explorer.Chain.Address.TokenBalance do use Ecto.Schema import Ecto.Changeset - import Ecto.Query, only: [from: 2, limit: 2, where: 3, subquery: 1, order_by: 3, preload: 2] + import Ecto.Query, only: [from: 2, subquery: 1] - alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain alias Explorer.Chain.Address.TokenBalance alias Explorer.Chain.{Address, Block, Hash, Token} - @default_paging_options %PagingOptions{page_size: 50} - @typedoc """ * `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. * `address_hash` - The address hash foreign key. @@ -84,43 +82,6 @@ defmodule Explorer.Chain.Address.TokenBalance do from(tb in subquery(query), where: tb.value > 0, preload: :token) end - @doc """ - Builds an `Ecto.Query` to fetch the token holders from the given token contract address hash. - - The Token Holders are the addresses that own a positive amount of the Token. So this query is - considering the following conditions: - - * The token balance from the last block. - * Balances greater than 0. - * Excluding the burn address (0x0000000000000000000000000000000000000000). - - """ - def token_holders_from_token_hash(token_contract_address_hash) do - query = token_holders_query(token_contract_address_hash) - - from(tb in subquery(query), where: tb.value > 0) - end - - def token_holders_ordered_by_value(token_contract_address_hash, options) do - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - token_contract_address_hash - |> token_holders_from_token_hash() - |> order_by([tb], desc: tb.value, desc: tb.address_hash) - |> preload(:address) - |> page_token_balances(paging_options) - |> limit(^paging_options.page_size) - end - - defp token_holders_query(contract_address_hash) do - from( - tb in TokenBalance, - distinct: :address_hash, - where: tb.token_contract_address_hash == ^contract_address_hash and tb.address_hash != ^@burn_address_hash, - order_by: [desc: :block_number] - ) - end - @doc """ Builds an `Ecto.Query` to group all tokens with their number of holders. """ @@ -144,16 +105,6 @@ defmodule Explorer.Chain.Address.TokenBalance do ) end - defp page_token_balances(query, %PagingOptions{key: nil}), do: query - - defp page_token_balances(query, %PagingOptions{key: {value, address_hash}}) do - where( - query, - [tb], - tb.value < ^value or (tb.value == ^value and tb.address_hash < ^address_hash) - ) - end - @doc """ Builds an `Ecto.Query` to fetch the unfetched token balances. diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index 3ba010e6be..28d62ee454 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -19,6 +19,7 @@ defmodule Explorer.Chain.Import do Import.Logs, Import.Tokens, Import.TokenTransfers, + Import.Address.CurrentTokenBalances, Import.Address.TokenBalances ] diff --git a/apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex b/apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex new file mode 100644 index 0000000000..11cf8cf460 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/address/current_token_balances.ex @@ -0,0 +1,124 @@ +defmodule Explorer.Chain.Import.Address.CurrentTokenBalances do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Address.CurrentTokenBalance.t/0`. + """ + + require Ecto.Query + + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi} + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Import + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [CurrentTokenBalance.t()] + + @impl Import.Runner + def ecto_schema_module, do: CurrentTokenBalance + + @impl Import.Runner + def option_key, do: :address_current_token_balances + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :address_current_token_balances, fn _ -> + insert(changes_list, insert_options) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert([map()], %{ + optional(:on_conflict) => Import.Runner.on_conflict(), + required(:timeout) => timeout(), + required(:timestamps) => Import.timestamps() + }) :: + {:ok, [CurrentTokenBalance.t()]} + | {:error, [Changeset.t()]} + def insert(changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + {:ok, _} = + Import.insert_changes_list( + unique_token_balances(changes_list), + conflict_target: ~w(address_hash token_contract_address_hash)a, + on_conflict: on_conflict, + for: CurrentTokenBalance, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + end + + # Remove duplicated token balances based on `{address_hash, token_hash}` considering the last block + # to avoid `cardinality_violation` error in Postgres. This error happens when there are duplicated + # rows being inserted. + defp unique_token_balances(changes_list) do + changes_list + |> Enum.sort(&(&1.block_number > &2.block_number)) + |> Enum.uniq_by(fn %{address_hash: address_hash, token_contract_address_hash: token_hash} -> + {address_hash, token_hash} + end) + end + + defp default_on_conflict do + from( + current_token_balance in CurrentTokenBalance, + update: [ + set: [ + block_number: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.block_number ELSE ? END", + current_token_balance.block_number, + current_token_balance.block_number + ), + inserted_at: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.inserted_at ELSE ? END", + current_token_balance.block_number, + current_token_balance.inserted_at + ), + updated_at: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.updated_at ELSE ? END", + current_token_balance.block_number, + current_token_balance.updated_at + ), + value: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.value ELSE ? END", + current_token_balance.block_number, + current_token_balance.value + ), + value_fetched_at: + fragment( + "CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.value_fetched_at ELSE ? END", + current_token_balance.block_number, + current_token_balance.value_fetched_at + ) + ] + ] + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs b/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs new file mode 100644 index 0000000000..d497d1feda --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs @@ -0,0 +1,32 @@ +defmodule Explorer.Repo.Migrations.CreateAddressCurrentTokenBalances do + use Ecto.Migration + + def change do + create table(:address_current_token_balances) do + add(:address_hash, references(:addresses, column: :hash, type: :bytea), null: false) + add(:block_number, :bigint, null: false) + + add( + :token_contract_address_hash, + references(:tokens, column: :contract_address_hash, type: :bytea), + null: false + ) + + add(:value, :decimal, null: true) + add(:value_fetched_at, :utc_datetime, default: fragment("NULL"), null: true) + + timestamps(null: false, type: :utc_datetime) + end + + create(unique_index(:address_current_token_balances, ~w(address_hash token_contract_address_hash)a)) + + create( + index( + :address_current_token_balances, + [:value], + name: :address_current_token_balances_value, + where: "value IS NOT NULL" + ) + ) + end +end diff --git a/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs new file mode 100644 index 0000000000..f02538f96b --- /dev/null +++ b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs @@ -0,0 +1,149 @@ +defmodule Explorer.Chain.Address.CurrentTokenBalanceTest do + use Explorer.DataCase + + alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.Chain.Token + alias Explorer.Chain.Address.CurrentTokenBalance + + describe "token_holders_ordered_by_value/2" do + test "returns the last value for each address" do + %Token{contract_address_hash: contract_address_hash} = insert(:token) + address_a = insert(:address) + address_b = insert(:address) + + insert( + :address_current_token_balance, + address: address_a, + token_contract_address_hash: contract_address_hash, + value: 5000 + ) + + insert( + :address_current_token_balance, + address: address_b, + block_number: 1001, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + token_holders_count = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + |> Enum.count() + + assert token_holders_count == 2 + end + + test "sort by the highest value" do + %Token{contract_address_hash: contract_address_hash} = insert(:token) + address_a = insert(:address) + address_b = insert(:address) + address_c = insert(:address) + + insert( + :address_current_token_balance, + address: address_a, + token_contract_address_hash: contract_address_hash, + value: 5000 + ) + + insert( + :address_current_token_balance, + address: address_b, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + insert( + :address_current_token_balance, + address: address_c, + token_contract_address_hash: contract_address_hash, + value: 15000 + ) + + token_holders_values = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + |> Enum.map(&Decimal.to_integer(&1.value)) + + assert token_holders_values == [15_000, 5_000, 4_000] + end + + test "returns only token balances that have value greater than 0" do + %Token{contract_address_hash: contract_address_hash} = insert(:token) + + insert( + :address_current_token_balance, + token_contract_address_hash: contract_address_hash, + value: 0 + ) + + result = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + + assert result == [] + end + + test "ignores the burn address" do + {:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") + + burn_address = insert(:address, hash: burn_address_hash) + + %Token{contract_address_hash: contract_address_hash} = insert(:token) + + insert( + :address_current_token_balance, + address: burn_address, + token_contract_address_hash: contract_address_hash, + value: 1000 + ) + + result = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value() + |> Repo.all() + + assert result == [] + end + + test "paginates the result by value and different address" do + address_a = build(:address, hash: "0xcb2cf1fd3199584ac5faa16c6aca49472dc6495a") + address_b = build(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") + + %Token{contract_address_hash: contract_address_hash} = insert(:token) + + first_page = + insert( + :address_current_token_balance, + address: address_a, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + second_page = + insert( + :address_current_token_balance, + address: address_b, + token_contract_address_hash: contract_address_hash, + value: 4000 + ) + + paging_options = %PagingOptions{ + key: {first_page.value, first_page.address_hash}, + page_size: 2 + } + + result_paginated = + contract_address_hash + |> CurrentTokenBalance.token_holders_ordered_by_value(paging_options: paging_options) + |> Repo.all() + |> Enum.map(& &1.address_hash) + + assert result_paginated == [second_page.address_hash] + end + end +end diff --git a/apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs b/apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs new file mode 100644 index 0000000000..a78b7046ef --- /dev/null +++ b/apps/explorer/test/explorer/chain/import/address/address/current_token_balances_test.exs @@ -0,0 +1,95 @@ +defmodule Explorer.Chain.Import.Address.CurrentTokenBalancesTest do + use Explorer.DataCase + + alias Explorer.Chain.Import.Address.CurrentTokenBalances + + alias Explorer.Chain.{Address.CurrentTokenBalance} + + describe "insert/2" do + setup do + address = insert(:address, hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca") + token = insert(:token) + + insert_options = %{ + timeout: :infinity, + timestamps: %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + } + + %{address: address, token: token, insert_options: insert_options} + end + + test "inserts in the current token balances", %{address: address, token: token, insert_options: insert_options} do + changes = [ + %{ + address_hash: address.hash, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(100) + } + ] + + CurrentTokenBalances.insert(changes, insert_options) + + current_token_balances = + CurrentTokenBalance + |> Explorer.Repo.all() + |> Enum.count() + + assert current_token_balances == 1 + end + + test "considers the last block upserting", %{address: address, token: token, insert_options: insert_options} do + insert( + :address_current_token_balance, + address: address, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + value: 100 + ) + + changes = [ + %{ + address_hash: address.hash, + block_number: 2, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(200) + } + ] + + CurrentTokenBalances.insert(changes, insert_options) + + current_token_balance = Explorer.Repo.get_by(CurrentTokenBalance, address_hash: address.hash) + + assert current_token_balance.block_number == 2 + assert current_token_balance.value == Decimal.new(200) + end + + test "considers the last block when there are duplicated params", %{ + address: address, + token: token, + insert_options: insert_options + } do + changes = [ + %{ + address_hash: address.hash, + block_number: 4, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(200) + }, + %{ + address_hash: address.hash, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + value: Decimal.new(100) + } + ] + + CurrentTokenBalances.insert(changes, insert_options) + + current_token_balance = Explorer.Repo.get_by(CurrentTokenBalance, address_hash: address.hash) + + assert current_token_balance.block_number == 4 + assert current_token_balance.value == Decimal.new(200) + end + end +end diff --git a/apps/explorer/test/explorer/chain/import_test.exs b/apps/explorer/test/explorer/chain/import_test.exs index ea4a485f6f..89c1c4f3eb 100644 --- a/apps/explorer/test/explorer/chain/import_test.exs +++ b/apps/explorer/test/explorer/chain/import_test.exs @@ -6,6 +6,7 @@ defmodule Explorer.Chain.ImportTest do alias Explorer.Chain.{ Address, Address.TokenBalance, + Address.CurrentTokenBalance, Block, Data, Log, @@ -395,6 +396,55 @@ defmodule Explorer.Chain.ImportTest do assert 3 == count end + test "inserts a current_token_balance" do + params = %{ + addresses: %{ + params: [ + %{hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca"}, + %{hash: "0x515c09c5bba1ed566b02a5b0599ec5d5d0aee73d"}, + %{hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"} + ], + timeout: 5 + }, + tokens: %{ + on_conflict: :nothing, + params: [ + %{ + contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + type: "ERC-20" + } + ], + timeout: 5 + }, + address_current_token_balances: %{ + params: [ + %{ + address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + token_contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + block_number: "37", + value: 200 + }, + %{ + address_hash: "0x515c09c5bba1ed566b02a5b0599ec5d5d0aee73d", + token_contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + block_number: "37", + value: 100 + } + ], + timeout: 5 + } + } + + Import.all(params) + + count = + CurrentTokenBalance + |> Explorer.Repo.all() + |> Enum.count() + + assert count == 2 + end + test "with empty map" do assert {:ok, %{}} == Import.all(%{}) end diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 414a54e217..0ed17acb4e 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -1182,7 +1182,10 @@ defmodule Explorer.ChainTest do |> Enum.map(&insert(:address, fetched_coin_balance: &1)) |> Enum.map(& &1.hash) - assert address_hashes == Enum.map(Chain.list_top_addresses(), & &1.hash) + assert address_hashes == + Chain.list_top_addresses() + |> Enum.map(fn {address, _transaction_count} -> address end) + |> Enum.map(& &1.hash) end test "with top addresses in order with matching value" do @@ -1201,7 +1204,10 @@ defmodule Explorer.ChainTest do |> insert(fetched_coin_balance: 4, hash: Enum.fetch!(test_hashes, 4)) |> Map.fetch!(:hash) - assert [first_result_hash | tail] == Enum.map(Chain.list_top_addresses(), & &1.hash) + assert [first_result_hash | tail] == + Chain.list_top_addresses() + |> Enum.map(fn {address, _transaction_count} -> address end) + |> Enum.map(& &1.hash) end end @@ -2956,173 +2962,32 @@ defmodule Explorer.ChainTest do end describe "fetch_token_holders_from_token_hash/2" do - test "returns the last value for each address" do + test "returns the token holders" do %Token{contract_address_hash: contract_address_hash} = insert(:token) - address = insert(:address) + address_a = insert(:address) + address_b = insert(:address) insert( - :token_balance, - address: address, - block_number: 1000, + :address_current_token_balance, + address: address_a, token_contract_address_hash: contract_address_hash, value: 5000 ) insert( - :token_balance, - block_number: 1001, - token_contract_address_hash: contract_address_hash, - value: 4000 - ) - - insert( - :token_balance, - address: address, - block_number: 1002, - token_contract_address_hash: contract_address_hash, - value: 2000 - ) - - values = - contract_address_hash - |> Chain.fetch_token_holders_from_token_hash([]) - |> Enum.map(&Decimal.to_integer(&1.value)) - - assert values == [4000, 2000] - end - - test "sort by the highest value" do - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - block_number: 1000, - token_contract_address_hash: contract_address_hash, - value: 2000 - ) - - insert( - :token_balance, + :address_current_token_balance, + address: address_b, block_number: 1001, token_contract_address_hash: contract_address_hash, - value: 1000 - ) - - insert( - :token_balance, - block_number: 1002, - token_contract_address_hash: contract_address_hash, value: 4000 ) - insert( - :token_balance, - block_number: 1002, - token_contract_address_hash: contract_address_hash, - value: 3000 - ) - - values = + token_holders_count = contract_address_hash |> Chain.fetch_token_holders_from_token_hash([]) - |> Enum.map(&Decimal.to_integer(&1.value)) - - assert values == [4000, 3000, 2000, 1000] - end - - test "returns only token balances that have value" do - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - token_contract_address_hash: contract_address_hash, - value: 0 - ) - - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] - end - - test "returns an empty list when there are no address with value greater than 0" do - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert(:token_balance, value: 1000) - - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] - end - - test "ignores the burn address" do - {:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") - - burn_address = insert(:address, hash: burn_address_hash) - - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - address: burn_address, - token_contract_address_hash: contract_address_hash, - value: 1000 - ) - - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] - end - - test "paginates the result by value and different address" do - address_a = build(:address, hash: "0xcb2cf1fd3199584ac5faa16c6aca49472dc6495a") - address_b = build(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") - - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - first_page = - insert( - :token_balance, - address: address_a, - token_contract_address_hash: contract_address_hash, - value: 4000 - ) - - second_page = - insert( - :token_balance, - address: address_b, - token_contract_address_hash: contract_address_hash, - value: 4000 - ) - - paging_options = %PagingOptions{ - key: {first_page.value, first_page.address_hash}, - page_size: 2 - } - - holders_paginated = - contract_address_hash - |> Chain.fetch_token_holders_from_token_hash(paging_options: paging_options) - |> Enum.map(& &1.address_hash) - - assert holders_paginated == [second_page.address_hash] - end - - test "considers the last block only if it has value" do - address = insert(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") - %Token{contract_address_hash: contract_address_hash} = insert(:token) - - insert( - :token_balance, - address: address, - block_number: 1000, - token_contract_address_hash: contract_address_hash, - value: 5000 - ) - - insert( - :token_balance, - address: address, - block_number: 1002, - token_contract_address_hash: contract_address_hash, - value: 0 - ) + |> Enum.count() - assert Chain.fetch_token_holders_from_token_hash(contract_address_hash, []) == [] + assert token_holders_count == 2 end end diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index f168091195..54f99731a8 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -13,6 +13,7 @@ defmodule Explorer.Factory do alias Explorer.Chain.{ Address, + Address.CurrentTokenBalance, Address.TokenBalance, Address.CoinBalance, Block, @@ -480,6 +481,16 @@ defmodule Explorer.Factory do } end + def address_current_token_balance_factory() do + %CurrentTokenBalance{ + address: build(:address), + token_contract_address_hash: insert(:token).contract_address_hash, + block_number: block_number(), + value: Enum.random(1..100_000), + value_fetched_at: DateTime.utc_now() + } + end + defmacrop left + right do quote do fragment("? + ?", unquote(left), unquote(right)) diff --git a/apps/indexer/config/config.exs b/apps/indexer/config/config.exs index ac8db25c30..43ff7c3d18 100644 --- a/apps/indexer/config/config.exs +++ b/apps/indexer/config/config.exs @@ -5,9 +5,10 @@ use Mix.Config import Bitwise config :indexer, + block_transformer: Indexer.Block.Transform.Base, + ecto_repos: [Explorer.Repo], # bytes - memory_limit: 1 <<< 30, - ecto_repos: [Explorer.Repo] + memory_limit: 1 <<< 30 config :logger, :indexer, # keep synced with `config/config.exs` diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index 07f35ee5c0..41a705a869 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -9,6 +9,7 @@ defmodule Indexer.Block.Fetcher do alias Indexer.{AddressExtraction, CoinBalance, MintTransfer, Token, TokenTransfers} alias Indexer.Address.{CoinBalances, TokenBalances} alias Indexer.Block.Fetcher.Receipts + alias Indexer.Block.Transform @type address_hash_to_fetched_balance_block_number :: %{String.t() => Block.block_number()} @type transaction_hash_to_block_number :: %{String.t() => Block.block_number()} @@ -96,6 +97,7 @@ defmodule Indexer.Block.Fetcher do transactions: transactions_without_receipts, block_second_degree_relations: block_second_degree_relations } = result, + blocks = Transform.transform_blocks(blocks), {:receipts, {:ok, receipt_params}} <- {:receipts, Receipts.fetch(state, transactions_without_receipts)}, %{logs: logs, receipts: receipts} = receipt_params, transactions_with_receipts = Receipts.put(transactions_without_receipts, receipts), diff --git a/apps/indexer/lib/indexer/block/realtime/fetcher.ex b/apps/indexer/lib/indexer/block/realtime/fetcher.ex index f84e19ffc1..88682c149a 100644 --- a/apps/indexer/lib/indexer/block/realtime/fetcher.ex +++ b/apps/indexer/lib/indexer/block/realtime/fetcher.ex @@ -107,6 +107,7 @@ defmodule Indexer.Block.Realtime.Fetcher do |> put_in([:addresses, :params], balances_addresses_params) |> put_in([:blocks, :params, Access.all(), :consensus], true) |> put_in([Access.key(:address_coin_balances, %{}), :params], balances_params) + |> put_in([Access.key(:address_current_token_balances, %{}), :params], address_token_balances) |> put_in([Access.key(:address_token_balances), :params], address_token_balances) |> put_in([Access.key(:internal_transactions, %{}), :params], internal_transactions_params), {:ok, imported} = ok <- Chain.import(chain_import_options) do diff --git a/apps/indexer/lib/indexer/block/transform.ex b/apps/indexer/lib/indexer/block/transform.ex new file mode 100644 index 0000000000..72f7f46f58 --- /dev/null +++ b/apps/indexer/lib/indexer/block/transform.ex @@ -0,0 +1,31 @@ +defmodule Indexer.Block.Transform do + @moduledoc """ + Protocol for transforming blocks. + """ + + @type block :: map() + + @doc """ + Transforms a block. + """ + @callback transform(block :: block()) :: block() + + @doc """ + Runs a list of blocks through the configured block transformer. + """ + def transform_blocks(blocks) when is_list(blocks) do + transformer = Application.get_env(:indexer, :block_transformer) + + unless transformer do + raise ArgumentError, + """ + No block transformer defined. Set a blocker transformer." + + config :indexer, + block_transformer: Indexer.Block.Transform.Base + """ + end + + Enum.map(blocks, &transformer.transform/1) + end +end diff --git a/apps/indexer/lib/indexer/block/transform/base.ex b/apps/indexer/lib/indexer/block/transform/base.ex new file mode 100644 index 0000000000..c094f9b1bc --- /dev/null +++ b/apps/indexer/lib/indexer/block/transform/base.ex @@ -0,0 +1,14 @@ +defmodule Indexer.Block.Transform.Base do + @moduledoc """ + Default block transformer to be used. + """ + + alias Indexer.Block.Transform + + @behaviour Transform + + @impl Transform + def transform(block) when is_map(block) do + block + end +end diff --git a/apps/indexer/lib/indexer/block/transform/clique.ex b/apps/indexer/lib/indexer/block/transform/clique.ex new file mode 100644 index 0000000000..bafab1e509 --- /dev/null +++ b/apps/indexer/lib/indexer/block/transform/clique.ex @@ -0,0 +1,16 @@ +defmodule Indexer.Block.Transform.Clique do + @moduledoc """ + Handles block transforms for Clique chain. + """ + + alias Indexer.Block.{Transform, Util} + + @behaviour Transform + + @impl Transform + def transform(block) when is_map(block) do + miner_address = Util.signer(block) + + %{block | miner_hash: miner_address} + end +end diff --git a/apps/indexer/lib/indexer/block/util.ex b/apps/indexer/lib/indexer/block/util.ex new file mode 100644 index 0000000000..fbf0d77997 --- /dev/null +++ b/apps/indexer/lib/indexer/block/util.ex @@ -0,0 +1,75 @@ +defmodule Indexer.Block.Util do + @moduledoc """ + Helper functions for parsing block information. + """ + + @doc """ + Calculates the signer's address by recovering the ECDSA public key. + + https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm + """ + def signer(block) when is_map(block) do + # Last 65 bytes is the signature. Multiply by two since we haven't transformed to raw bytes + {extra_data, signature} = String.split_at(trim_prefix(block.extra_data), -130) + + block = %{block | extra_data: extra_data} + + signature_hash = signature_hash(block) + + recover_pub_key(signature_hash, decode(signature)) + end + + # Signature hash calculated from the block header. + # Needed for PoA-based chains + defp signature_hash(block) do + header_data = [ + decode(block.parent_hash), + decode(block.sha3_uncles), + decode(block.miner_hash), + decode(block.state_root), + decode(block.transactions_root), + decode(block.receipts_root), + decode(block.logs_bloom), + block.difficulty, + block.number, + block.gas_limit, + block.gas_used, + DateTime.to_unix(block.timestamp), + decode(block.extra_data), + decode(block.mix_hash), + decode(block.nonce) + ] + + :keccakf1600.hash(:sha3_256, ExRLP.encode(header_data)) + end + + defp trim_prefix("0x" <> rest), do: rest + + defp decode("0x" <> rest) do + decode(rest) + end + + defp decode(data) do + Base.decode16!(data, case: :mixed) + end + + # Recovers the key from the signature hash and signature + defp recover_pub_key(signature_hash, signature) do + << + r::bytes-size(32), + s::bytes-size(32), + v::integer-size(8) + >> = signature + + # First byte represents compression which can be ignored + # Private key is the last 64 bytes + {:ok, <<_compression::bytes-size(1), private_key::binary>>} = + :libsecp256k1.ecdsa_recover_compact(signature_hash, r <> s, :uncompressed, v) + + # Public key comes from the last 20 bytes + <<_::bytes-size(12), public_key::binary>> = :keccakf1600.hash(:sha3_256, private_key) + + miner_address = Base.encode16(public_key, case: :lower) + "0x" <> miner_address + end +end diff --git a/apps/indexer/lib/indexer/token_balance/fetcher.ex b/apps/indexer/lib/indexer/token_balance/fetcher.ex index 19df1853f9..f8cf8e65a6 100644 --- a/apps/indexer/lib/indexer/token_balance/fetcher.ex +++ b/apps/indexer/lib/indexer/token_balance/fetcher.ex @@ -80,7 +80,13 @@ defmodule Indexer.TokenBalance.Fetcher do end def import_token_balances(token_balances_params) do - case Chain.import(%{address_token_balances: %{params: token_balances_params}, timeout: :infinity}) do + import_params = %{ + address_token_balances: %{params: token_balances_params}, + address_current_token_balances: %{params: token_balances_params}, + timeout: :infinity + } + + case Chain.import(import_params) do {:ok, _} -> :ok diff --git a/apps/indexer/lib/indexer/token_balances.ex b/apps/indexer/lib/indexer/token_balances.ex index 5e96615790..a92720eac1 100644 --- a/apps/indexer/lib/indexer/token_balances.ex +++ b/apps/indexer/lib/indexer/token_balances.ex @@ -29,7 +29,7 @@ defmodule Indexer.TokenBalances do token_balances |> Task.async_stream(&fetch_token_balance/1, on_timeout: :kill_task) |> Stream.map(&format_task_results/1) - |> Enum.filter(&ignore_request_with_timeouts/1) + |> Enum.filter(&ignore_request_with_errors/1) token_balances |> MapSet.new() @@ -70,11 +70,12 @@ defmodule Indexer.TokenBalances do |> TokenBalance.Fetcher.async_fetch() end - def format_task_results({:exit, :timeout}), do: {:error, :timeout} - def format_task_results({:ok, token_balance}), do: token_balance + defp format_task_results({:exit, :timeout}), do: {:error, :timeout} + defp format_task_results({:ok, token_balance}), do: token_balance - def ignore_request_with_timeouts({:error, :timeout}), do: false - def ignore_request_with_timeouts(_token_balance), do: true + defp ignore_request_with_errors({:error, :timeout}), do: false + defp ignore_request_with_errors(%{value: nil, value_fetched_at: nil, error: _error}), do: false + defp ignore_request_with_errors(_token_balance), do: true def log_fetching_errors(from, token_balances_params) do error_messages = diff --git a/apps/indexer/mix.exs b/apps/indexer/mix.exs index f8b26a7f47..44b5d3dea5 100644 --- a/apps/indexer/mix.exs +++ b/apps/indexer/mix.exs @@ -46,10 +46,14 @@ defmodule Indexer.MixProject do [ # JSONRPC access to Parity for `Explorer.Indexer` {:ethereum_jsonrpc, in_umbrella: true}, + # RLP encoding + {:ex_rlp, "~> 0.3"}, # Code coverage {:excoveralls, "~> 0.10.0", only: [:test], github: "KronicDeth/excoveralls", branch: "circle-workflows"}, # Importing to database {:explorer, in_umbrella: true}, + # libsecp2561k1 crypto functions + {:libsecp256k1, "~> 0.1.10"}, # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, # Mocking `EthereumJSONRPC.Transport`, so we avoid hitting real chains for local testing diff --git a/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs b/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs index 1e1bf69492..07d97fc87b 100644 --- a/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs +++ b/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs @@ -448,20 +448,26 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do jsonrpc: "2.0", result: %{ "difficulty" => "0x0", + "extraData" => "0x0", "gasLimit" => "0x0", "gasUsed" => "0x0", "hash" => Explorer.Factory.block_hash() |> to_string(), + "logsBloom" => "0x0", "miner" => "0xb2930b35844a230f00e51431acae96fe543a0347", "number" => "0x0", "parentHash" => Explorer.Factory.block_hash() |> to_string(), + "receiptsRoot" => "0x0", + "sha3Uncles" => "0x0", "size" => "0x0", + "stateRoot" => "0x0", "timestamp" => "0x0", "totalDifficulty" => "0x0", "transactions" => [], + "transactionsRoot" => "0x0", "uncles" => [] } } diff --git a/apps/indexer/test/indexer/block/transform/base_test.exs b/apps/indexer/test/indexer/block/transform/base_test.exs new file mode 100644 index 0000000000..b55adebc57 --- /dev/null +++ b/apps/indexer/test/indexer/block/transform/base_test.exs @@ -0,0 +1,42 @@ +defmodule Indexer.Block.Transform.BaseTest do + use ExUnit.Case + + alias Indexer.Block.Transform.Base + + @block %{ + difficulty: 1, + extra_data: + "0xd68301080d846765746886676f312e3130856c696e7578000000000000000000773ab2ca8f47904a14739ad80a75b71d9d29b9fff8b7ecdcb73efffa6f74122f17d304b5dc8e6e5f256c9474dd115c8d4dae31b7a3d409e5c3270f8fde41cd8c00", + gas_limit: 7_753_377, + gas_used: 1_810_195, + hash: "0x7004c895e812c55b0c2be8a46d72ca300a683dc27d1d7917ee7742d4d0359c1f", + logs_bloom: + "0x00000000000000020000000000002000000400000000000000000000000000000000000000000000040000080004000020000010000000000000000000000000000000000000000008000008000000000000000000200000000000000000000000000000020000000000000000000800000000000000804000000010080000000800000000000000000000000000000000000000000000800000000000080000000008000400000000404000000000000000000000000200000000000000000000000002000000000000001002000000000000002000000008000000000020000000000000000000000000000000000000000000000000400000800000000000", + miner: "0x0000000000000000000000000000000000000000", + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", + number: 2_848_394, + parent_hash: "0x20350fc367e19d3865be1ea7da72ab81f8f9941c43ac6bb24a34a0a7caa2f3df", + receipts_root: "0x6ade4ac1079ea50cfadcce2b75ffbe4f9b14bf69b4607bbf1739463076ca6246", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + size: 6437, + state_root: "0x23f63347851bcd109059d007d71e19c4f5e73b7f0862bebcd04458333a004d92", + timestamp: DateTime.from_unix!(1_534_796_040), + total_difficulty: 5_353_647, + transactions: [ + "0x7e3bb851fc74a436826d2af6b96e4db9484431811ef0d9c9e78370488d33d4e5", + "0x3976fd1e3d2a715c3cfcfde9bd3210798c26c017b8edb841d319227ecb3322fb", + "0xd8db124005bb8b6fda7b71fd56ac782552a66af58fe843ba3c4930423b87d1d2", + "0x10c1a1ca4d9f4b2bd5b89f7bbcbbc2d69e166fe23662b8db4f6beae0f50ac9fd", + "0xaa58a6545677c796a56b8bc874174c8cfd31a6c6e6ca3a87e086d4f66d52858a" + ], + transactions_root: "0xde8d25c0b9b54310128a21601331094b43f910f9f96102869c2e2dca94884bf4", + uncles: [] + } + + describe "transform/1" do + test "passes the block through unchanged" do + assert Base.transform(@block) == @block + end + end +end diff --git a/apps/indexer/test/indexer/block/transform/clique_test.exs b/apps/indexer/test/indexer/block/transform/clique_test.exs new file mode 100644 index 0000000000..4af5d05895 --- /dev/null +++ b/apps/indexer/test/indexer/block/transform/clique_test.exs @@ -0,0 +1,43 @@ +defmodule Indexer.Block.Transform.CliqueTest do + use ExUnit.Case + + alias Indexer.Block.Transform.Clique + + @block %{ + difficulty: 1, + extra_data: + "0xd68301080d846765746886676f312e3130856c696e7578000000000000000000773ab2ca8f47904a14739ad80a75b71d9d29b9fff8b7ecdcb73efffa6f74122f17d304b5dc8e6e5f256c9474dd115c8d4dae31b7a3d409e5c3270f8fde41cd8c00", + gas_limit: 7_753_377, + gas_used: 1_810_195, + hash: "0x7004c895e812c55b0c2be8a46d72ca300a683dc27d1d7917ee7742d4d0359c1f", + logs_bloom: + "0x00000000000000020000000000002000000400000000000000000000000000000000000000000000040000080004000020000010000000000000000000000000000000000000000008000008000000000000000000200000000000000000000000000000020000000000000000000800000000000000804000000010080000000800000000000000000000000000000000000000000000800000000000080000000008000400000000404000000000000000000000000200000000000000000000000002000000000000001002000000000000002000000008000000000020000000000000000000000000000000000000000000000000400000800000000000", + miner_hash: "0x0000000000000000000000000000000000000000", + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", + number: 2_848_394, + parent_hash: "0x20350fc367e19d3865be1ea7da72ab81f8f9941c43ac6bb24a34a0a7caa2f3df", + receipts_root: "0x6ade4ac1079ea50cfadcce2b75ffbe4f9b14bf69b4607bbf1739463076ca6246", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + size: 6437, + state_root: "0x23f63347851bcd109059d007d71e19c4f5e73b7f0862bebcd04458333a004d92", + timestamp: DateTime.from_unix!(1_534_796_040), + total_difficulty: 5_353_647, + transactions: [ + "0x7e3bb851fc74a436826d2af6b96e4db9484431811ef0d9c9e78370488d33d4e5", + "0x3976fd1e3d2a715c3cfcfde9bd3210798c26c017b8edb841d319227ecb3322fb", + "0xd8db124005bb8b6fda7b71fd56ac782552a66af58fe843ba3c4930423b87d1d2", + "0x10c1a1ca4d9f4b2bd5b89f7bbcbbc2d69e166fe23662b8db4f6beae0f50ac9fd", + "0xaa58a6545677c796a56b8bc874174c8cfd31a6c6e6ca3a87e086d4f66d52858a" + ], + transactions_root: "0xde8d25c0b9b54310128a21601331094b43f910f9f96102869c2e2dca94884bf4", + uncles: [] + } + + describe "transform/1" do + test "updates the miner hash with signer address" do + expected = %{@block | miner_hash: "0xfc18cbc391de84dbd87db83b20935d3e89f5dd91"} + assert Clique.transform(@block) == expected + end + end +end diff --git a/apps/indexer/test/indexer/block/transform_test.exs b/apps/indexer/test/indexer/block/transform_test.exs new file mode 100644 index 0000000000..dd6bb0b84e --- /dev/null +++ b/apps/indexer/test/indexer/block/transform_test.exs @@ -0,0 +1,56 @@ +defmodule Indexer.Block.TransformTest do + use ExUnit.Case + + alias Indexer.Block.Transform + + @block %{ + difficulty: 1, + extra_data: + "0xd68301080d846765746886676f312e3130856c696e7578000000000000000000773ab2ca8f47904a14739ad80a75b71d9d29b9fff8b7ecdcb73efffa6f74122f17d304b5dc8e6e5f256c9474dd115c8d4dae31b7a3d409e5c3270f8fde41cd8c00", + gas_limit: 7_753_377, + gas_used: 1_810_195, + hash: "0x7004c895e812c55b0c2be8a46d72ca300a683dc27d1d7917ee7742d4d0359c1f", + logs_bloom: + "0x00000000000000020000000000002000000400000000000000000000000000000000000000000000040000080004000020000010000000000000000000000000000000000000000008000008000000000000000000200000000000000000000000000000020000000000000000000800000000000000804000000010080000000800000000000000000000000000000000000000000000800000000000080000000008000400000000404000000000000000000000000200000000000000000000000002000000000000001002000000000000002000000008000000000020000000000000000000000000000000000000000000000000400000800000000000", + miner_hash: "0x0000000000000000000000000000000000000000", + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", + number: 2_848_394, + parent_hash: "0x20350fc367e19d3865be1ea7da72ab81f8f9941c43ac6bb24a34a0a7caa2f3df", + receipts_root: "0x6ade4ac1079ea50cfadcce2b75ffbe4f9b14bf69b4607bbf1739463076ca6246", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + size: 6437, + state_root: "0x23f63347851bcd109059d007d71e19c4f5e73b7f0862bebcd04458333a004d92", + timestamp: DateTime.from_unix!(1_534_796_040), + total_difficulty: 5_353_647, + transactions: [ + "0x7e3bb851fc74a436826d2af6b96e4db9484431811ef0d9c9e78370488d33d4e5", + "0x3976fd1e3d2a715c3cfcfde9bd3210798c26c017b8edb841d319227ecb3322fb", + "0xd8db124005bb8b6fda7b71fd56ac782552a66af58fe843ba3c4930423b87d1d2", + "0x10c1a1ca4d9f4b2bd5b89f7bbcbbc2d69e166fe23662b8db4f6beae0f50ac9fd", + "0xaa58a6545677c796a56b8bc874174c8cfd31a6c6e6ca3a87e086d4f66d52858a" + ], + transactions_root: "0xde8d25c0b9b54310128a21601331094b43f910f9f96102869c2e2dca94884bf4", + uncles: [] + } + + @blocks [@block, @block] + + describe "transform_blocks/1" do + setup do + original = Application.get_env(:indexer, :block_transformer) + + on_exit(fn -> Application.put_env(:indexer, :block_transformer, original) end) + end + + test "transforms a list of blocks" do + assert Transform.transform_blocks(@blocks) + end + + test "raises when no transformer is configured" do + Application.put_env(:indexer, :block_transformer, nil) + + assert_raise ArgumentError, fn -> Transform.transform_blocks(@blocks) end + end + end +end diff --git a/apps/indexer/test/indexer/block/uncle/fetcher_test.exs b/apps/indexer/test/indexer/block/uncle/fetcher_test.exs index 970f1700a0..5c70683aa7 100644 --- a/apps/indexer/test/indexer/block/uncle/fetcher_test.exs +++ b/apps/indexer/test/indexer/block/uncle/fetcher_test.exs @@ -69,6 +69,9 @@ defmodule Indexer.Block.Uncle.FetcherTest do "number" => number_quantity, "parentHash" => "0x006edcaa1e6fde822908783bc4ef1ad3675532d542fce53537557391cfe34c3c", "size" => "0x243", + "receiptsRoot" => "0x0", + "sha3Uncles" => "0x0", + "stateRoot" => "0x0", "timestamp" => "0x5b437f41", "totalDifficulty" => "0x342337ffffffffffffffffffffffffed8d29bb", "transactions" => [ @@ -93,6 +96,7 @@ defmodule Indexer.Block.Uncle.FetcherTest do "value" => "0x0" } ], + "transactionsRoot" => "0x0", "uncles" => [uncle_uncle_hash_data] } } diff --git a/apps/indexer/test/indexer/block/util_test.exs b/apps/indexer/test/indexer/block/util_test.exs new file mode 100644 index 0000000000..f0228cb1be --- /dev/null +++ b/apps/indexer/test/indexer/block/util_test.exs @@ -0,0 +1,40 @@ +defmodule Indexer.Block.UtilTest do + use ExUnit.Case + + alias Indexer.Block.Util + + test "signer/1" do + data = %{ + difficulty: 1, + extra_data: + "0xd68301080d846765746886676f312e3130856c696e7578000000000000000000773ab2ca8f47904a14739ad80a75b71d9d29b9fff8b7ecdcb73efffa6f74122f17d304b5dc8e6e5f256c9474dd115c8d4dae31b7a3d409e5c3270f8fde41cd8c00", + gas_limit: 7_753_377, + gas_used: 1_810_195, + hash: "0x7004c895e812c55b0c2be8a46d72ca300a683dc27d1d7917ee7742d4d0359c1f", + logs_bloom: + "0x00000000000000020000000000002000000400000000000000000000000000000000000000000000040000080004000020000010000000000000000000000000000000000000000008000008000000000000000000200000000000000000000000000000020000000000000000000800000000000000804000000010080000000800000000000000000000000000000000000000000000800000000000080000000008000400000000404000000000000000000000000200000000000000000000000002000000000000001002000000000000002000000008000000000020000000000000000000000000000000000000000000000000400000800000000000", + miner_hash: "0x0000000000000000000000000000000000000000", + mix_hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nonce: "0x0000000000000000", + number: 2_848_394, + parent_hash: "0x20350fc367e19d3865be1ea7da72ab81f8f9941c43ac6bb24a34a0a7caa2f3df", + receipts_root: "0x6ade4ac1079ea50cfadcce2b75ffbe4f9b14bf69b4607bbf1739463076ca6246", + sha3_uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + size: 6437, + state_root: "0x23f63347851bcd109059d007d71e19c4f5e73b7f0862bebcd04458333a004d92", + timestamp: DateTime.from_unix!(1_534_796_040), + total_difficulty: 5_353_647, + transactions: [ + "0x7e3bb851fc74a436826d2af6b96e4db9484431811ef0d9c9e78370488d33d4e5", + "0x3976fd1e3d2a715c3cfcfde9bd3210798c26c017b8edb841d319227ecb3322fb", + "0xd8db124005bb8b6fda7b71fd56ac782552a66af58fe843ba3c4930423b87d1d2", + "0x10c1a1ca4d9f4b2bd5b89f7bbcbbc2d69e166fe23662b8db4f6beae0f50ac9fd", + "0xaa58a6545677c796a56b8bc874174c8cfd31a6c6e6ca3a87e086d4f66d52858a" + ], + transactions_root: "0xde8d25c0b9b54310128a21601331094b43f910f9f96102869c2e2dca94884bf4", + uncles: [] + } + + assert Util.signer(data) == "0xfc18cbc391de84dbd87db83b20935d3e89f5dd91" + end +end diff --git a/apps/indexer/test/indexer/token_balances_test.exs b/apps/indexer/test/indexer/token_balances_test.exs index c59cd9e034..378559dc2e 100644 --- a/apps/indexer/test/indexer/token_balances_test.exs +++ b/apps/indexer/test/indexer/token_balances_test.exs @@ -45,28 +45,21 @@ defmodule Indexer.TokenBalancesTest do } = List.first(result) end - test "does not ignore calls that were returned with error" do - address = insert(:address) + test "ignores calls that gave errors to try fetch they again later" do + address = insert(:address, hash: "0x7113ffcb9c18a97da1b9cfc43e6cb44ed9165509") token = insert(:token, contract_address: build(:contract_address)) - address_hash_string = Hash.to_string(address.hash) - data = %{ - token_contract_address_hash: token.contract_address_hash, - address_hash: address_hash_string, - block_number: 1_000 - } + token_balances = [ + %{ + address_hash: to_string(address.hash), + block_number: 1_000, + token_contract_address_hash: to_string(token.contract_address_hash) + } + ] get_balance_from_blockchain_with_error() - {:ok, result} = TokenBalances.fetch_token_balances_from_blockchain([data]) - - assert %{ - value: nil, - token_contract_address_hash: token_contract_address_hash, - address_hash: address_hash, - block_number: 1_000, - value_fetched_at: nil - } = List.first(result) + assert TokenBalances.fetch_token_balances_from_blockchain(token_balances) == {:ok, []} end test "ignores results that raised :timeout" do diff --git a/mix.lock b/mix.lock index 8c75e5a4c7..4a62ae5e91 100644 --- a/mix.lock +++ b/mix.lock @@ -32,6 +32,7 @@ "ex_cldr_units": {:hex, :ex_cldr_units, "1.1.1", "b3c7256709bdeb3740a5f64ce2bce659eb9cf4cc1afb4cf94aba033b4a18bc5f", [:mix], [{:ex_cldr, "~> 1.0", [hex: :ex_cldr, optional: false]}, {:ex_cldr_numbers, "~> 1.0", [hex: :ex_cldr_numbers, optional: false]}]}, "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, "ex_machina": {:hex, :ex_machina, "2.2.1", "df84d0b23487aaa8570c35e586d7f9f197a7787e1121344a41d8832a7ea41edf", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, + "ex_rlp": {:hex, :ex_rlp, "0.3.1", "190554f7b26f79734fc5a772241eec14a71b2e83576e43f451479feb017013e9", [:mix], [], "hexpm"}, "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], []}, "excoveralls": {:git, "https://github.com/KronicDeth/excoveralls.git", "0a859b68851eeba9b43eba59fbc8f9098299cfe1", [branch: "circle-workflows"]}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, optional: false]}]}, @@ -50,7 +51,7 @@ "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], []}, "junit_formatter": {:hex, :junit_formatter, "2.2.0", "da6093f0740c58a824f9585ebb7cb1b960efaecf48d1fa969e95d9c47c6b19dd", [:mix], [], "hexpm"}, "keccakf1600": {:hex, :keccakf1600_orig, "2.0.0", "0a7217ddb3ee8220d449bbf7575ec39d4e967099f220a91e3dfca4dbaef91963", [:rebar3], []}, - "libsecp256k1": {:hex, :libsecp256k1, "0.1.4", "42b7f76d8e32f85f578ccda0abfdb1afa0c5c231d1fd8aeab9cda352731a2d83", [:rebar3], []}, + "libsecp256k1": {:hex, :libsecp256k1, "0.1.10", "d27495e2b9851c7765129b76c53b60f5e275bd6ff68292c50536bf6b8d091a4d", [:make, :mix], [{:mix_erlang_tasks, "0.1.0", [hex: :mix_erlang_tasks, repo: "hexpm", optional: false]}], "hexpm"}, "logger_file_backend": {:hex, :logger_file_backend, "0.0.10", "876f9f84ae110781207c54321ffbb62bebe02946fe3c13f0d7c5f5d8ad4fa910", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, @@ -59,6 +60,7 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, + "mix_erlang_tasks": {:hex, :mix_erlang_tasks, "0.1.0", "36819fec60b80689eb1380938675af215565a89320a9e29c72c70d97512e4649", [:mix], [], "hexpm"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.2", "e98e998fd76c191c7e1a9557c8617912c53df3d4a6132f561eb762b699ef59fa", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "mox": {:hex, :mox, "0.4.0", "7f120840f7d626184a3d65de36189ca6f37d432e5d63acd80045198e4c5f7e6e", [:mix], [], "hexpm"},