[services/wallet] Several changes in API after feedback (#1517)
[services/wallet] Several changes in API after feedback - Timestamp from block header stored in blocks table and added to each transfers - From field is computed from signature to offload this computation from client side - `history` event is added back, so that client can ignore historical blocks when watching only for new blocks - block number and timestamp are marshalled in hex. consistent with ethereum data structures
This commit is contained in:
parent
03cf452b8f
commit
e20648ecc7
|
@ -46,82 +46,36 @@ Returns avaiable transfers in a given range.
|
||||||
|
|
||||||
List of objects like:
|
List of objects like:
|
||||||
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"type": "erc20",
|
"type": "eth",
|
||||||
"address": "0x5dc6108dc6296b052bbd33000553afe0ea576b5e",
|
"address": "0xd448dbe70b62304fc157319e00a041eea238c5eb",
|
||||||
"blockNumber": 5687981,
|
"blockNumber": "0x1",
|
||||||
"blockhash": "0xcc4553f125be0bc6cc974518368145fcf1344f41e5de238205db0a1c185ea2fc",
|
"blockhash": "0x2c8f84bc61572e82b39c7dc6bf067d7e71e5d53e745b1174e1b7df4938df0053",
|
||||||
|
"timestamp": "0x2332",
|
||||||
"transaction": {
|
"transaction": {
|
||||||
"nonce": "0x57",
|
"nonce": "0x0",
|
||||||
"gasPrice": "0x3b9aca00",
|
"gasPrice": "0x1",
|
||||||
"gas": "0x44ba8",
|
"gas": "0x5208",
|
||||||
"to": "0xc55cf4b03948d7ebc8b9e8bad92643703811d162",
|
"to": "0xd448dbe70b62304fc157319e00a041eea238c5eb",
|
||||||
"value": "0x0",
|
"value": "0x16345785d8a0000",
|
||||||
"input": "0xcae9ca5100000000000000000000000039d16cdb56b5a6a89e1a397a13fe48034694316e0000000000000000000000000000000000000000000000015af1d78b58c40000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000449134709e00000000000000000000000000000000000000000000000000000000000000010000000000000000000000005dc6108dc6296b052bbd33000553afe0ea576b5e00000000000000000000000000000000000000000000000000000000",
|
"input": "0x",
|
||||||
"v": "0x29",
|
"v": "0xa95",
|
||||||
"r": "0x124587e9c1d16d8bd02fda1221aefbfca8e2f4cd6300ed2077ebf736789179ab",
|
"r": "0x73159b07b55d810b3898b60a0e3aed87e59e097be6bcae508a9b60e3e1f0ec3a",
|
||||||
"s": "0x4309fddc1226dacb877488221a439c4f97d77dc2c3f5c8ea51f34f42417d3bda",
|
"s": "0x2b58524c9b96228e1e996a1e236a52e4a10beb54aad7c9ee1bf36b613f4d9cfb",
|
||||||
"hash": "0x259dd45c9c4d52137f32b7787e6e1fb6c9faf70ba40b8137bf66ba03abc0da00"
|
"hash": "0x23da761563d8aa59398649df43a89a9ae3a7497861313674c401481b7400e8f9"
|
||||||
},
|
},
|
||||||
|
"from": "0x27bc544041e129501a6e6fb3c54cf6f12970b1e3",
|
||||||
"receipt": {
|
"receipt": {
|
||||||
"root": "0x",
|
"root": "0x",
|
||||||
"status": "0x1",
|
"status": "0x1",
|
||||||
"cumulativeGasUsed": "0x389e1e",
|
"cumulativeGasUsed": "0x5208",
|
||||||
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000200000000020000000000000000000000000000000000004000000000000000200000000000000020000000000008000000000000000000000000000000000000000000000000020000000000002000000800000000100000000000000010000000000000000000400000000000000001000000000040000000400000000400000000020000000000000008000000000020000000010000000002000000000000020000000002000000000000000000000000000000000200000000000000000020000010000000000000000000000400000000000000000000000000000000000000",
|
"logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||||
"logs": [
|
"logs": [],
|
||||||
{
|
"transactionHash": "0x23da761563d8aa59398649df43a89a9ae3a7497861313674c401481b7400e8f9",
|
||||||
"address": "0xc55cf4b03948d7ebc8b9e8bad92643703811d162",
|
|
||||||
"topics": [
|
|
||||||
"0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925",
|
|
||||||
"0x0000000000000000000000005dc6108dc6296b052bbd33000553afe0ea576b5e",
|
|
||||||
"0x00000000000000000000000039d16cdb56b5a6a89e1a397a13fe48034694316e"
|
|
||||||
],
|
|
||||||
"data": "0x0000000000000000000000000000000000000000000000015af1d78b58c40000",
|
|
||||||
"blockNumber": "0x56caad",
|
|
||||||
"transactionHash": "0x259dd45c9c4d52137f32b7787e6e1fb6c9faf70ba40b8137bf66ba03abc0da00",
|
|
||||||
"transactionIndex": "0x10",
|
|
||||||
"blockHash": "0xcc4553f125be0bc6cc974518368145fcf1344f41e5de238205db0a1c185ea2fc",
|
|
||||||
"logIndex": "0xd",
|
|
||||||
"removed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "0xc55cf4b03948d7ebc8b9e8bad92643703811d162",
|
|
||||||
"topics": [
|
|
||||||
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
|
|
||||||
"0x0000000000000000000000005dc6108dc6296b052bbd33000553afe0ea576b5e",
|
|
||||||
"0x000000000000000000000000ee55b1661fd24c4760d92026cedb252a5a0f2a4e"
|
|
||||||
],
|
|
||||||
"data": "0x0000000000000000000000000000000000000000000000015af1d78b58c40000",
|
|
||||||
"blockNumber": "0x56caad",
|
|
||||||
"transactionHash": "0x259dd45c9c4d52137f32b7787e6e1fb6c9faf70ba40b8137bf66ba03abc0da00",
|
|
||||||
"transactionIndex": "0x10",
|
|
||||||
"blockHash": "0xcc4553f125be0bc6cc974518368145fcf1344f41e5de238205db0a1c185ea2fc",
|
|
||||||
"logIndex": "0xe",
|
|
||||||
"removed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "0x39d16cdb56b5a6a89e1a397a13fe48034694316e",
|
|
||||||
"topics": [
|
|
||||||
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
|
|
||||||
"0x0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
"0x0000000000000000000000005dc6108dc6296b052bbd33000553afe0ea576b5e",
|
|
||||||
"0x0000000000000000000000000000000000000000000000000000000000000044"
|
|
||||||
],
|
|
||||||
"data": "0x",
|
|
||||||
"blockNumber": "0x56caad",
|
|
||||||
"transactionHash": "0x259dd45c9c4d52137f32b7787e6e1fb6c9faf70ba40b8137bf66ba03abc0da00",
|
|
||||||
"transactionIndex": "0x10",
|
|
||||||
"blockHash": "0xcc4553f125be0bc6cc974518368145fcf1344f41e5de238205db0a1c185ea2fc",
|
|
||||||
"logIndex": "0xf",
|
|
||||||
"removed": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"transactionHash": "0x259dd45c9c4d52137f32b7787e6e1fb6c9faf70ba40b8137bf66ba03abc0da00",
|
|
||||||
"contractAddress": "0x0000000000000000000000000000000000000000",
|
"contractAddress": "0x0000000000000000000000000000000000000000",
|
||||||
"gasUsed": "0x34f42"
|
"gasUsed": "0x5208"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -226,3 +180,21 @@ Client expected to request new transfers from received block and replace transfe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. `history` signal
|
||||||
|
|
||||||
|
Emmited when historical transfers were downloaded. Block number will refer the first block where historical transfers
|
||||||
|
were found.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "wallet",
|
||||||
|
"event": {
|
||||||
|
"type": "history",
|
||||||
|
"blockNumber": 0,
|
||||||
|
"accounts": [
|
||||||
|
"0x42c8f505b4006d417dd4e0ba0e880692986adbd8"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
|
@ -68,7 +68,7 @@ func (c *ethHistoricalCommand) Run(ctx context.Context) (err error) {
|
||||||
if len(transfers) > 0 {
|
if len(transfers) > 0 {
|
||||||
// we download all or nothing
|
// we download all or nothing
|
||||||
c.feed.Send(Event{
|
c.feed.Send(Event{
|
||||||
Type: EventNewBlock,
|
Type: EventNewHistory,
|
||||||
BlockNumber: c.from,
|
BlockNumber: c.from,
|
||||||
Accounts: []common.Address{c.address},
|
Accounts: []common.Address{c.address},
|
||||||
})
|
})
|
||||||
|
@ -123,7 +123,7 @@ func (c *erc20HistoricalCommand) Run(ctx context.Context) (err error) {
|
||||||
if len(transfers) > 0 {
|
if len(transfers) > 0 {
|
||||||
log.Debug("erc20 downloader imported transfers", "len", len(transfers), "time", time.Since(start))
|
log.Debug("erc20 downloader imported transfers", "len", len(transfers), "time", time.Since(start))
|
||||||
c.feed.Send(Event{
|
c.feed.Send(Event{
|
||||||
Type: EventNewBlock,
|
Type: EventNewHistory,
|
||||||
BlockNumber: c.iterator.Header().Number,
|
BlockNumber: c.iterator.Header().Number,
|
||||||
Accounts: []common.Address{c.address},
|
Accounts: []common.Address{c.address},
|
||||||
})
|
})
|
||||||
|
@ -313,7 +313,7 @@ func (c *controlCommand) fastIndex(ctx context.Context, to *DBHeader) error {
|
||||||
for _, address := range c.accounts {
|
for _, address := range c.accounts {
|
||||||
erc20 := &erc20HistoricalCommand{
|
erc20 := &erc20HistoricalCommand{
|
||||||
db: c.db,
|
db: c.db,
|
||||||
erc20: NewERC20TransfersDownloader(c.client, []common.Address{address}),
|
erc20: NewERC20TransfersDownloader(c.client, []common.Address{address}, types.NewEIP155Signer(c.chain)),
|
||||||
client: c.client,
|
client: c.client,
|
||||||
feed: c.feed,
|
feed: c.feed,
|
||||||
address: address,
|
address: address,
|
||||||
|
@ -442,6 +442,7 @@ func headersFromTransfers(transfers []Transfer) []*DBHeader {
|
||||||
rst = append(rst, &DBHeader{
|
rst = append(rst, &DBHeader{
|
||||||
Hash: transfers[i].BlockHash,
|
Hash: transfers[i].BlockHash,
|
||||||
Number: transfers[i].BlockNumber,
|
Number: transfers[i].BlockNumber,
|
||||||
|
Timestamp: transfers[i].Timestamp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rst
|
return rst
|
||||||
|
|
|
@ -43,7 +43,7 @@ func (s *NewBlocksSuite) SetupTest() {
|
||||||
s.cmd = &newBlocksTransfersCommand{
|
s.cmd = &newBlocksTransfersCommand{
|
||||||
db: s.db,
|
db: s.db,
|
||||||
accounts: []common.Address{s.address},
|
accounts: []common.Address{s.address},
|
||||||
erc20: NewERC20TransfersDownloader(s.backend.Client, []common.Address{s.address}),
|
erc20: NewERC20TransfersDownloader(s.backend.Client, []common.Address{s.address}, s.backend.Signer),
|
||||||
eth: ÐTransferDownloader{
|
eth: ÐTransferDownloader{
|
||||||
client: s.backend.Client,
|
client: s.backend.Client,
|
||||||
signer: s.backend.Signer,
|
signer: s.backend.Signer,
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
type DBHeader struct {
|
type DBHeader struct {
|
||||||
Number *big.Int
|
Number *big.Int
|
||||||
Hash common.Hash
|
Hash common.Hash
|
||||||
|
Timestamp uint64
|
||||||
// Head is true if the block was a head at the time it was pulled from chain.
|
// Head is true if the block was a head at the time it was pulled from chain.
|
||||||
Head bool
|
Head bool
|
||||||
}
|
}
|
||||||
|
@ -25,15 +26,14 @@ func toDBHeader(header *types.Header) *DBHeader {
|
||||||
return &DBHeader{
|
return &DBHeader{
|
||||||
Hash: header.Hash(),
|
Hash: header.Hash(),
|
||||||
Number: header.Number,
|
Number: header.Number,
|
||||||
|
Timestamp: header.Time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toHead(header *types.Header) *DBHeader {
|
func toHead(header *types.Header) *DBHeader {
|
||||||
return &DBHeader{
|
dbheader := toDBHeader(header)
|
||||||
Hash: header.Hash(),
|
dbheader.Head = true
|
||||||
Number: header.Number,
|
return dbheader
|
||||||
Head: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncOption is used to specify that application processed transfers for that block.
|
// SyncOption is used to specify that application processed transfers for that block.
|
||||||
|
@ -76,7 +76,7 @@ func (i *SQLBigInt) Scan(value interface{}) error {
|
||||||
// Value implements interface.
|
// Value implements interface.
|
||||||
func (i *SQLBigInt) Value() (driver.Value, error) {
|
func (i *SQLBigInt) Value() (driver.Value, error) {
|
||||||
if !(*big.Int)(i).IsInt64() {
|
if !(*big.Int)(i).IsInt64() {
|
||||||
return nil, errors.New("not at int64")
|
return nil, errors.New("not an int64")
|
||||||
}
|
}
|
||||||
return (*big.Int)(i).Int64(), nil
|
return (*big.Int)(i).Int64(), nil
|
||||||
}
|
}
|
||||||
|
@ -168,12 +168,6 @@ func (db *Database) GetTransfers(start, end *big.Int) (rst []Transfer, err error
|
||||||
return query.Scan(rows)
|
return query.Scan(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveHeader stores a single header.
|
|
||||||
func (db *Database) SaveHeader(header *types.Header) error {
|
|
||||||
_, err := db.db.Exec("INSERT INTO blocks(number, hash) VALUES (?, ?)", (*SQLBigInt)(header.Number), header.Hash())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveHeaders stores a list of headers atomically.
|
// SaveHeaders stores a list of headers atomically.
|
||||||
func (db *Database) SaveHeaders(headers []*types.Header) (err error) {
|
func (db *Database) SaveHeaders(headers []*types.Header) (err error) {
|
||||||
var (
|
var (
|
||||||
|
@ -184,7 +178,7 @@ func (db *Database) SaveHeaders(headers []*types.Header) (err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
insert, err = tx.Prepare("INSERT INTO blocks(number, hash) VALUES (?,?)")
|
insert, err = tx.Prepare("INSERT INTO blocks(number, hash, timestamp) VALUES (?, ?, ?)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -197,7 +191,7 @@ func (db *Database) SaveHeaders(headers []*types.Header) (err error) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for _, h := range headers {
|
for _, h := range headers {
|
||||||
_, err = insert.Exec((*SQLBigInt)(h.Number), h.Hash())
|
_, err = insert.Exec((*SQLBigInt)(h.Number), h.Hash(), h.Time)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -302,12 +296,12 @@ func deleteHeaders(creator statementCreator, headers []*DBHeader) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertHeaders(creator statementCreator, headers []*DBHeader) error {
|
func insertHeaders(creator statementCreator, headers []*DBHeader) error {
|
||||||
insert, err := creator.Prepare("INSERT OR IGNORE INTO blocks(hash, number, head) VALUES (?, ?, ?)")
|
insert, err := creator.Prepare("INSERT OR IGNORE INTO blocks(hash, number, timestamp, head) VALUES (?, ?, ?, ?)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, h := range headers {
|
for _, h := range headers {
|
||||||
_, err = insert.Exec(h.Hash, (*SQLBigInt)(h.Number), h.Head)
|
_, err = insert.Exec(h.Hash, (*SQLBigInt)(h.Number), h.Timestamp, h.Head)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -316,12 +310,12 @@ func insertHeaders(creator statementCreator, headers []*DBHeader) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertTransfers(creator statementCreator, transfers []Transfer) error {
|
func insertTransfers(creator statementCreator, transfers []Transfer) error {
|
||||||
insert, err := creator.Prepare("INSERT OR IGNORE INTO transfers(hash, blk_hash, address, tx, receipt, type) VALUES (?, ?, ?, ?, ?, ?)")
|
insert, err := creator.Prepare("INSERT OR IGNORE INTO transfers(hash, blk_hash, address, tx, sender, receipt, type) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, t := range transfers {
|
for _, t := range transfers {
|
||||||
_, err = insert.Exec(t.ID, t.BlockHash, t.Address, &JSONBlob{t.Transaction}, &JSONBlob{t.Receipt}, t.Type)
|
_, err = insert.Exec(t.ID, t.BlockHash, t.Address, &JSONBlob{t.Transaction}, t.From, &JSONBlob{t.Receipt}, t.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ func TestDBGetHeaderByNumber(t *testing.T) {
|
||||||
Difficulty: big.NewInt(1),
|
Difficulty: big.NewInt(1),
|
||||||
Time: 1,
|
Time: 1,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.SaveHeader(header))
|
require.NoError(t, db.SaveHeaders([]*types.Header{header}))
|
||||||
rst, err := db.GetHeaderByNumber(header.Number)
|
rst, err := db.GetHeaderByNumber(header.Number)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, header.Hash(), rst.Hash)
|
require.Equal(t, header.Hash(), rst.Hash)
|
||||||
|
@ -52,7 +52,7 @@ func TestDBHeaderExists(t *testing.T) {
|
||||||
Difficulty: big.NewInt(1),
|
Difficulty: big.NewInt(1),
|
||||||
Time: 1,
|
Time: 1,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.SaveHeader(header))
|
require.NoError(t, db.SaveHeaders([]*types.Header{header}))
|
||||||
rst, err := db.HeaderExists(header.Hash())
|
rst, err := db.HeaderExists(header.Hash())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, rst)
|
require.True(t, rst)
|
||||||
|
@ -104,10 +104,10 @@ func TestDBReorgTransfers(t *testing.T) {
|
||||||
originalTX := types.NewTransaction(1, common.Address{1}, nil, 10, big.NewInt(10), nil)
|
originalTX := types.NewTransaction(1, common.Address{1}, nil, 10, big.NewInt(10), nil)
|
||||||
replacedTX := types.NewTransaction(2, common.Address{1}, nil, 10, big.NewInt(10), nil)
|
replacedTX := types.NewTransaction(2, common.Address{1}, nil, 10, big.NewInt(10), nil)
|
||||||
require.NoError(t, db.ProcessTranfers([]Transfer{
|
require.NoError(t, db.ProcessTranfers([]Transfer{
|
||||||
{ethTransfer, common.Hash{1}, *originalTX.To(), original.Number, original.Hash, originalTX, rcpt},
|
{ethTransfer, common.Hash{1}, *originalTX.To(), original.Number, original.Hash, 100, originalTX, common.Address{1}, rcpt},
|
||||||
}, nil, []*DBHeader{original}, nil, 0))
|
}, nil, []*DBHeader{original}, nil, 0))
|
||||||
require.NoError(t, db.ProcessTranfers([]Transfer{
|
require.NoError(t, db.ProcessTranfers([]Transfer{
|
||||||
{ethTransfer, common.Hash{2}, *replacedTX.To(), replaced.Number, replaced.Hash, replacedTX, rcpt},
|
{ethTransfer, common.Hash{2}, *replacedTX.To(), replaced.Number, replaced.Hash, 100, replacedTX, common.Address{1}, rcpt},
|
||||||
}, nil, []*DBHeader{replaced}, []*DBHeader{original}, 0))
|
}, nil, []*DBHeader{replaced}, []*DBHeader{original}, 0))
|
||||||
|
|
||||||
all, err := db.GetTransfers(big.NewInt(0), nil)
|
all, err := db.GetTransfers(big.NewInt(0), nil)
|
||||||
|
@ -166,8 +166,7 @@ func TestDBLatestSynced(t *testing.T) {
|
||||||
Difficulty: big.NewInt(1),
|
Difficulty: big.NewInt(1),
|
||||||
Time: 1,
|
Time: 1,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.SaveHeader(h1))
|
require.NoError(t, db.SaveHeaders([]*types.Header{h1, h2}))
|
||||||
require.NoError(t, db.SaveHeader(h2))
|
|
||||||
require.NoError(t, db.SaveSyncedHeader(address, h1, ethSync))
|
require.NoError(t, db.SaveSyncedHeader(address, h1, ethSync))
|
||||||
require.NoError(t, db.SaveSyncedHeader(address, h2, ethSync))
|
require.NoError(t, db.SaveSyncedHeader(address, h2, ethSync))
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,14 @@ package wallet
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
@ -38,7 +40,54 @@ type Transfer struct {
|
||||||
Address common.Address `json:"address"`
|
Address common.Address `json:"address"`
|
||||||
BlockNumber *big.Int `json:"blockNumber"`
|
BlockNumber *big.Int `json:"blockNumber"`
|
||||||
BlockHash common.Hash `json:"blockhash"`
|
BlockHash common.Hash `json:"blockhash"`
|
||||||
|
Timestamp uint64 `json:"timestamp"`
|
||||||
Transaction *types.Transaction `json:"transaction"`
|
Transaction *types.Transaction `json:"transaction"`
|
||||||
|
// From is derived from tx signature in order to offload this computation from UI component.
|
||||||
|
From common.Address `json:"from"`
|
||||||
|
Receipt *types.Receipt `json:"receipt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t Transfer) MarshalJSON() ([]byte, error) {
|
||||||
|
m := transferMarshaling{}
|
||||||
|
m.Type = t.Type
|
||||||
|
m.Address = t.Address
|
||||||
|
m.BlockNumber = (*hexutil.Big)(t.BlockNumber)
|
||||||
|
m.BlockHash = t.BlockHash
|
||||||
|
m.Timestamp = hexutil.Uint64(t.Timestamp)
|
||||||
|
m.Transaction = t.Transaction
|
||||||
|
m.From = t.From
|
||||||
|
m.Receipt = t.Receipt
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transfer) UnmarshalJSON(input []byte) error {
|
||||||
|
m := transferMarshaling{}
|
||||||
|
err := json.Unmarshal(input, &m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.Type = m.Type
|
||||||
|
t.Address = m.Address
|
||||||
|
t.BlockNumber = (*big.Int)(m.BlockNumber)
|
||||||
|
t.BlockHash = m.BlockHash
|
||||||
|
t.Timestamp = uint64(m.Timestamp)
|
||||||
|
t.Transaction = m.Transaction
|
||||||
|
m.From = t.From
|
||||||
|
m.Receipt = t.Receipt
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transferMarshaling ensures that all integers will be marshalled with hexutil
|
||||||
|
// to be consistent with types.Transaction and types.Receipt.
|
||||||
|
type transferMarshaling struct {
|
||||||
|
Type TransferType `json:"type"`
|
||||||
|
Address common.Address `json:"address"`
|
||||||
|
BlockNumber *hexutil.Big `json:"blockNumber"`
|
||||||
|
BlockHash common.Hash `json:"blockhash"`
|
||||||
|
Timestamp hexutil.Uint64 `json:"timestamp"`
|
||||||
|
Transaction *types.Transaction `json:"transaction"`
|
||||||
|
// From is derived from tx signature in order to offload this computation from UI component.
|
||||||
|
From common.Address `json:"from"`
|
||||||
Receipt *types.Receipt `json:"receipt"`
|
Receipt *types.Receipt `json:"receipt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,7 +170,10 @@ func (d *ETHTransferDownloader) getTransfersInBlock(ctx context.Context, blk *ty
|
||||||
Address: *address,
|
Address: *address,
|
||||||
BlockNumber: blk.Number(),
|
BlockNumber: blk.Number(),
|
||||||
BlockHash: blk.Hash(),
|
BlockHash: blk.Hash(),
|
||||||
Transaction: tx, Receipt: receipt})
|
Timestamp: blk.Time(),
|
||||||
|
Transaction: tx,
|
||||||
|
From: from,
|
||||||
|
Receipt: receipt})
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -130,12 +182,13 @@ func (d *ETHTransferDownloader) getTransfersInBlock(ctx context.Context, blk *ty
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewERC20TransfersDownloader returns new instance.
|
// NewERC20TransfersDownloader returns new instance.
|
||||||
func NewERC20TransfersDownloader(client *ethclient.Client, accounts []common.Address) *ERC20TransfersDownloader {
|
func NewERC20TransfersDownloader(client *ethclient.Client, accounts []common.Address, signer types.Signer) *ERC20TransfersDownloader {
|
||||||
signature := crypto.Keccak256Hash([]byte(erc20TransferEventSignature))
|
signature := crypto.Keccak256Hash([]byte(erc20TransferEventSignature))
|
||||||
return &ERC20TransfersDownloader{
|
return &ERC20TransfersDownloader{
|
||||||
client: client,
|
client: client,
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
|
signer: signer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,6 +199,9 @@ type ERC20TransfersDownloader struct {
|
||||||
|
|
||||||
// hash of the Transfer event signature
|
// hash of the Transfer event signature
|
||||||
signature common.Hash
|
signature common.Hash
|
||||||
|
|
||||||
|
// signer is used to derive tx sender from tx signature
|
||||||
|
signer types.Signer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *ERC20TransfersDownloader) paddedAddress(address common.Address) common.Hash {
|
func (d *ERC20TransfersDownloader) paddedAddress(address common.Address) common.Hash {
|
||||||
|
@ -169,12 +225,22 @@ func (d *ERC20TransfersDownloader) transferFromLog(parent context.Context, log t
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Transfer{}, err
|
return Transfer{}, err
|
||||||
}
|
}
|
||||||
|
from, err := types.Sender(d.signer, tx)
|
||||||
|
if err != nil {
|
||||||
|
return Transfer{}, err
|
||||||
|
}
|
||||||
ctx, cancel = context.WithTimeout(parent, 3*time.Second)
|
ctx, cancel = context.WithTimeout(parent, 3*time.Second)
|
||||||
receipt, err := d.client.TransactionReceipt(ctx, log.TxHash)
|
receipt, err := d.client.TransactionReceipt(ctx, log.TxHash)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Transfer{}, err
|
return Transfer{}, err
|
||||||
}
|
}
|
||||||
|
ctx, cancel = context.WithTimeout(parent, 3*time.Second)
|
||||||
|
blk, err := d.client.BlockByHash(ctx, log.BlockHash)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return Transfer{}, err
|
||||||
|
}
|
||||||
// TODO(dshulyak) what is the max number of logs?
|
// TODO(dshulyak) what is the max number of logs?
|
||||||
index := [4]byte{}
|
index := [4]byte{}
|
||||||
binary.BigEndian.PutUint32(index[:], uint32(log.Index))
|
binary.BigEndian.PutUint32(index[:], uint32(log.Index))
|
||||||
|
@ -186,7 +252,9 @@ func (d *ERC20TransfersDownloader) transferFromLog(parent context.Context, log t
|
||||||
BlockNumber: new(big.Int).SetUint64(log.BlockNumber),
|
BlockNumber: new(big.Int).SetUint64(log.BlockNumber),
|
||||||
BlockHash: log.BlockHash,
|
BlockHash: log.BlockHash,
|
||||||
Transaction: tx,
|
Transaction: tx,
|
||||||
|
From: from,
|
||||||
Receipt: receipt,
|
Receipt: receipt,
|
||||||
|
Timestamp: blk.Time(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -138,6 +138,7 @@ type ERC20TransferSuite struct {
|
||||||
|
|
||||||
func (s *ERC20TransferSuite) SetupTest() {
|
func (s *ERC20TransferSuite) SetupTest() {
|
||||||
var err error
|
var err error
|
||||||
|
s.signer = types.NewEIP155Signer(big.NewInt(1337))
|
||||||
s.identity, err = crypto.GenerateKey()
|
s.identity, err = crypto.GenerateKey()
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.faucet, err = crypto.GenerateKey()
|
s.faucet, err = crypto.GenerateKey()
|
||||||
|
@ -150,7 +151,7 @@ func (s *ERC20TransferSuite) SetupTest() {
|
||||||
client, err := node.Attach()
|
client, err := node.Attach()
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.ethclient = ethclient.NewClient(client)
|
s.ethclient = ethclient.NewClient(client)
|
||||||
s.downloader = NewERC20TransfersDownloader(s.ethclient, []common.Address{crypto.PubkeyToAddress(s.identity.PublicKey)})
|
s.downloader = NewERC20TransfersDownloader(s.ethclient, []common.Address{crypto.PubkeyToAddress(s.identity.PublicKey)}, s.signer)
|
||||||
|
|
||||||
_, tx, contract, err := erc20.DeployERC20Transfer(bind.NewKeyedTransactor(s.faucet), s.ethclient)
|
_, tx, contract, err := erc20.DeployERC20Transfer(bind.NewKeyedTransactor(s.faucet), s.ethclient)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
|
@ -159,7 +160,6 @@ func (s *ERC20TransferSuite) SetupTest() {
|
||||||
_, err = bind.WaitMined(timeout, s.ethclient, tx)
|
_, err = bind.WaitMined(timeout, s.ethclient, tx)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.contract = contract
|
s.contract = contract
|
||||||
s.signer = types.NewEIP155Signer(big.NewInt(1337))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ERC20TransferSuite) TestNoEvents() {
|
func (s *ERC20TransferSuite) TestNoEvents() {
|
||||||
|
|
|
@ -25,7 +25,7 @@ func bindata_read(data []byte, name string) ([]byte, error) {
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var __0001_transfers_down_db_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x28\x29\x4a\xcc\x2b\x4e\x4b\x2d\x2a\xb6\xe6\x42\x12\x4d\xca\xc9\x4f\xce\x2e\xb6\xe6\x02\x04\x00\x00\xff\xff\x27\x4d\x7a\xa1\x29\x00\x00\x00")
|
var __0001_transfers_down_db_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x28\x29\x4a\xcc\x2b\x4e\x4b\x2d\x2a\xb6\xe6\x42\x12\x4d\xca\xc9\x4f\xce\x46\x15\x4a\x4c\x4e\xce\x2f\xcd\x2b\x29\x8e\x2f\xc9\x8f\x87\x49\x03\x02\x00\x00\xff\xff\xe1\x80\x1c\xac\x48\x00\x00\x00")
|
||||||
|
|
||||||
func _0001_transfers_down_db_sql() ([]byte, error) {
|
func _0001_transfers_down_db_sql() ([]byte, error) {
|
||||||
return bindata_read(
|
return bindata_read(
|
||||||
|
@ -34,7 +34,7 @@ func _0001_transfers_down_db_sql() ([]byte, error) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var __0001_transfers_up_db_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x8c\x91\xd1\x72\xaa\x30\x10\x86\xef\xf3\x14\x7b\x29\x33\x79\x83\x73\x15\x60\xd1\xcc\xc9\x49\x4e\x43\xa8\xf5\x8a\x41\x4c\xab\xa3\x06\x4a\x60\xa6\xbe\x7d\x07\x01\xad\xad\xe3\xf4\x32\xbb\xd9\xdd\xef\xff\xff\x48\x23\x33\x08\x86\x85\x02\x81\x27\x20\x95\x01\x7c\xe1\xa9\x49\xa1\x6d\x0a\xe7\x5f\x6d\xe3\x61\x46\xb6\x85\xdf\xc2\x33\xd3\xd1\x82\x69\xc8\x24\x7f\xca\x90\x92\x62\xb3\x69\xac\xf7\x97\x7a\x3f\x2b\x33\x21\x28\x59\x1f\xf6\xf9\xcd\xc8\xb5\xd5\x7e\x40\x28\x54\x48\x49\x63\x4b\xbb\xab\xdb\xf1\xd5\x9e\x6a\x7b\xe7\x77\xa2\x34\xf2\xb9\x84\xbf\xb8\x9a\x4d\x4b\x03\xd0\x98\xa0\x46\x19\x61\x0a\xeb\x43\x55\xee\xfd\x6c\xa8\x2b\x09\x31\x0a\x34\x08\x11\x4b\x23\x16\x23\x25\x91\x92\xa9\xd1\x8c\x4b\x03\x9d\xdb\xbd\x77\x36\x9f\x64\xe5\x95\x3b\xaf\xcb\x27\x19\x83\x2c\x38\xef\xa2\x63\x31\x20\xc1\x1f\x42\x1e\x98\x34\xdc\xff\xee\xd0\x7f\xcd\xff\x31\xbd\xea\xb1\x29\x71\xdd\x71\x6d\x1b\x08\xf9\xbc\xa7\x18\xaf\x5c\x25\x6e\x6d\xb1\x81\x50\x29\x01\x31\x26\x2c\x13\x06\x12\x26\x52\x24\x01\x2c\xb9\x59\xa8\xcc\x80\x56\x4b\x1e\x3f\xc6\x28\xca\xb2\xea\x5c\xeb\xf3\xb6\xca\x2f\x48\x8f\xf3\xb9\xc5\xba\xf6\xfc\xc9\x95\xc0\xa5\xf9\x69\xfe\x30\x71\xcf\xfe\xa9\xf3\xab\x00\x8e\x45\x5d\xef\xdc\x5b\xef\xff\x48\x38\x20\x4f\x44\x53\x0e\x63\x93\x7e\x39\xdd\xa7\xf1\x19\x00\x00\xff\xff\xfc\x91\xad\x60\xb2\x02\x00\x00")
|
var __0001_transfers_up_db_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x8c\x91\xcf\x8e\x9b\x30\x10\xc6\xef\x7e\x8a\x39\x06\x89\x37\xe8\xc9\xc0\x90\x58\x75\xed\xd6\x98\xa6\x39\x21\x02\x6e\x83\x12\x0c\xc5\x20\x6d\xde\x7e\x45\x80\xfc\xd9\x8d\xa2\xbd\xce\x7c\x33\xdf\x6f\xe6\x0b\x15\x52\x8d\xa0\x69\xc0\x11\x58\x0c\x42\x6a\xc0\x3f\x2c\xd1\x09\xf4\x5d\x6e\xdd\x5f\xd3\x39\x58\x91\x43\xee\x0e\xf0\x9b\xaa\x70\x43\x15\xa4\x82\xfd\x4a\xd1\x27\x79\x59\x76\xc6\xb9\x6b\x7d\x9c\x15\x29\xe7\x3e\xd9\x9f\x8e\xd9\xc3\xc8\xad\xd5\xbf\x41\xc0\x65\xe0\x13\x67\x6c\x69\xba\x27\x8a\xce\x14\xa6\x6a\xfb\x59\xd6\x9f\x5b\xf3\x44\x14\x4b\x85\x6c\x2d\xe0\x3b\xee\x56\x8b\x9b\x07\x0a\x63\x54\x28\x42\x4c\x60\x7f\x6a\x8a\xa3\x5b\x4d\x75\x29\x20\x42\x8e\x1a\x21\xa4\x49\x48\x23\xf4\x49\x28\x45\xa2\x15\x65\x42\xc3\x60\xab\xff\x83\xc9\x96\x7b\xb3\xc6\x5e\xd6\x65\xcb\x7d\xd3\xbd\x70\xd9\xe5\xcf\x45\x8f\x78\xdf\x08\x79\xf1\xbd\xc9\xff\xe3\xeb\x7e\x2a\xf6\x83\xaa\xdd\x88\xed\x13\x3b\xd4\x7b\xd3\x41\xc0\xd6\x23\xc5\xec\x72\xf7\xa9\xaa\x36\xae\xcf\xeb\x16\x52\x91\xb0\xb5\xc0\x68\x91\xde\x34\x07\x93\x97\x10\x48\xc9\x21\xc2\x98\xa6\x5c\x43\x4c\x79\x82\xc4\x83\x2d\xd3\x1b\x99\x6a\x50\x72\xcb\xa2\xd7\xa8\x79\x51\x34\x83\xed\x5d\xd6\x37\xd9\x15\xfb\x75\xb8\x8f\xe8\xb7\x9e\x3b\xdb\x02\x98\xd0\x9f\x03\x9a\x26\x9e\x45\xb4\x74\xbe\x14\x52\x9d\xb7\x6d\x65\xff\x8d\x19\xcd\x84\x13\xf2\x42\xb4\x64\x35\x37\xfd\x3b\xeb\x31\xb1\xf7\x00\x00\x00\xff\xff\x76\x37\x2b\x31\xef\x02\x00\x00")
|
||||||
|
|
||||||
func _0001_transfers_up_db_sql() ([]byte, error) {
|
func _0001_transfers_up_db_sql() ([]byte, error) {
|
||||||
return bindata_read(
|
return bindata_read(
|
||||||
|
|
|
@ -3,6 +3,7 @@ hash VARCHAR UNIQUE,
|
||||||
address VARCHAR NOT NULL,
|
address VARCHAR NOT NULL,
|
||||||
blk_hash VARCHAR NOT NULL,
|
blk_hash VARCHAR NOT NULL,
|
||||||
tx BLOB,
|
tx BLOB,
|
||||||
|
sender VARCHAR NOT NULL,
|
||||||
receipt BLOB,
|
receipt BLOB,
|
||||||
type VARCHAR NOT NULL,
|
type VARCHAR NOT NULL,
|
||||||
FOREIGN KEY(blk_hash) REFERENCES blocks(hash) ON DELETE CASCADE,
|
FOREIGN KEY(blk_hash) REFERENCES blocks(hash) ON DELETE CASCADE,
|
||||||
|
@ -12,6 +13,7 @@ CONSTRAINT unique_transfer_on_hash_address UNIQUE (hash,address)
|
||||||
CREATE TABLE IF NOT EXISTS blocks (
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
hash VARCHAR PRIMARY KEY,
|
hash VARCHAR PRIMARY KEY,
|
||||||
number BIGINT UNIQUE NOT NULL,
|
number BIGINT UNIQUE NOT NULL,
|
||||||
|
timestamp UNSIGNED BIGINT NOT NULL,
|
||||||
head BOOL DEFAULT FALSE
|
head BOOL DEFAULT FALSE
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
|
|
@ -80,6 +80,7 @@ func (r *Reactor) Start() error {
|
||||||
return errors.New("already running")
|
return errors.New("already running")
|
||||||
}
|
}
|
||||||
r.group = NewGroup(context.Background())
|
r.group = NewGroup(context.Background())
|
||||||
|
signer := types.NewEIP155Signer(r.chain)
|
||||||
// TODO(dshulyak) to support adding accounts in runtime implement keyed group
|
// TODO(dshulyak) to support adding accounts in runtime implement keyed group
|
||||||
// and export private api to start downloaders from accounts
|
// and export private api to start downloaders from accounts
|
||||||
// private api should have access only to reactor
|
// private api should have access only to reactor
|
||||||
|
@ -91,9 +92,9 @@ func (r *Reactor) Start() error {
|
||||||
eth: ÐTransferDownloader{
|
eth: ÐTransferDownloader{
|
||||||
client: r.client,
|
client: r.client,
|
||||||
accounts: r.accounts,
|
accounts: r.accounts,
|
||||||
signer: types.NewEIP155Signer(r.chain),
|
signer: signer,
|
||||||
},
|
},
|
||||||
erc20: NewERC20TransfersDownloader(r.client, r.accounts),
|
erc20: NewERC20TransfersDownloader(r.client, r.accounts, signer),
|
||||||
feed: r.feed,
|
feed: r.feed,
|
||||||
safetyDepth: reorgSafetyDepth,
|
safetyDepth: reorgSafetyDepth,
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseTransfersQuery = "SELECT transfers.hash, type, blocks.hash, blocks.number, address, tx, receipt FROM transfers JOIN blocks ON blk_hash = blocks.hash"
|
const baseTransfersQuery = "SELECT transfers.hash, type, blocks.hash, blocks.number, blocks.timestamp, address, tx, sender, receipt FROM transfers JOIN blocks ON blk_hash = blocks.hash"
|
||||||
|
|
||||||
func newTransfersQuery() *transfersQuery {
|
func newTransfersQuery() *transfersQuery {
|
||||||
buf := bytes.NewBuffer(nil)
|
buf := bytes.NewBuffer(nil)
|
||||||
|
@ -75,8 +75,9 @@ func (q *transfersQuery) Scan(rows *sql.Rows) (rst []Transfer, err error) {
|
||||||
Receipt: &types.Receipt{},
|
Receipt: &types.Receipt{},
|
||||||
}
|
}
|
||||||
err = rows.Scan(
|
err = rows.Scan(
|
||||||
&transfer.ID, &transfer.Type, &transfer.BlockHash, (*SQLBigInt)(transfer.BlockNumber), &transfer.Address,
|
&transfer.ID, &transfer.Type, &transfer.BlockHash,
|
||||||
&JSONBlob{transfer.Transaction}, &JSONBlob{transfer.Receipt})
|
(*SQLBigInt)(transfer.BlockNumber), &transfer.Timestamp, &transfer.Address,
|
||||||
|
&JSONBlob{transfer.Transaction}, &transfer.From, &JSONBlob{transfer.Receipt})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
|
@ -106,8 +106,8 @@ func (s *TransfersSuite) TestHistoricalTransfers() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(all) >= 30 {
|
if len(all) < 30 {
|
||||||
return fmt.Errorf("waiting for atleast 30 transfers")
|
return fmt.Errorf("waiting for atleast 30 transfers, got %d", len(all))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, 30*time.Second, 1*time.Second))
|
}, 30*time.Second, 1*time.Second))
|
||||||
|
|
Loading…
Reference in New Issue