Consolidate adapter interface

This commit is contained in:
Louis Holbrook 2021-08-26 08:05:56 +00:00
parent d2da81e5cb
commit 3d659ace3a
41 changed files with 1190 additions and 660 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
__pycache__
*.pyc
*.sqlite
build/
dist/
*.html
*.egg-info/

View File

@ -1,3 +1,7 @@
- 0.0.3
* cli tool for listing queue by address
* ensure lowercase hex input in db
* exportable unittest utilities
- 0.0.2
* add fs backend
* add flexible data directory

View File

@ -1 +1 @@
include *requirements.txt LICENSE
include *requirements.txt LICENSE chainqueue/db/migrations/default/* chainqueue/db/migrations/default/versions/* chainqueue/db/migrations/default/versions/src/* chainqueue/data/config/*

View File

@ -1,13 +1,104 @@
# standard imports
import datetime
# local imports
from chainqueue.enum import StatusBits
class Adapter:
"""Base class defining interface to be implemented by chainqueue adapters.
def __init__(self, backend):
The chainqueue adapter collects the following actions:
- add: add a transaction to the queue
- upcoming: get queued transactions ready to be sent to network
- dispatch: send a queued transaction to the network
- translate: decode details of a transaction
- create_session, release_session: session management to control queue state integrity
:param backend: Chainqueue backend
:type backend: TODO - abstract backend class. Must implement get, create_session, release_session
:param pending_retry_threshold: seconds delay before retrying a transaction stalled in the newtork
:type pending_retry_threshold: int
:param error_retry_threshold: seconds delay before retrying a transaction that incurred a recoverable error state
:type error_retry_threshold: int
"""
def __init__(self, backend, pending_retry_threshold=0, error_retry_threshold=0):
self.backend = backend
self.pending_retry_threshold = datetime.timedelta(pending_retry_threshold)
self.error_retry_threshold = datetime.timedelta(error_retry_threshold)
def process(self, chain_spec):
raise NotImplementedEror()
def add(self, bytecode, chain_spec, session=None):
"""Add a transaction to the queue.
:param bytecode: Transaction wire format bytecode, in hex
:type bytecode: str
:param chain_spec: Chain spec to use for transaction decode
:type chain_spec: chainlib.chain.ChainSpec
:param session: Backend state integrity session
:type session: varies
"""
raise NotImplementedError()
def add(self, chain_spec, bytecode):
raise NotImplementedEror()
def translate(self, bytecode, chain_spec):
"""Decode details of a transaction.
:param bytecode: Transaction wire format bytecode, in hex
:type bytecode: str
:param chain_spec: Chain spec to use for transaction decode
:type chain_spec: chainlib.chain.ChainSpec
"""
raise NotImplementedError()
def dispatch(self, chain_spec, rpc, tx_hash, signed_tx, session=None):
"""Send a queued transaction to the network.
:param chain_spec: Chain spec to use to identify the transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param rpc: RPC connection to use for transaction send
:type rpc: chainlib.connection.RPCConnection
:param tx_hash: Transaction hash (checksum of transaction), in hex
:type tx_hash: str
:param signed_tx: Transaction wire format bytecode, in hex
:type signed_tx: str
:param session: Backend state integrity session
:type session: varies
"""
raise NotImplementedError()
def upcoming(self, chain_spec, session=None):
"""Get queued transactions ready to be sent to the network.
The transactions will be a combination of newly submitted transactions, previously sent but stalled transactions, and transactions that could temporarily not be submitted.
:param chain_spec: Chain spec to use to identify the transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param session: Backend state integrity session
:type session: varies
"""
raise NotImplementedError()
def create_session(self, session=None):
"""Create a session context to guarantee atomic state change in backend.
:param session: If specified, session will be used instead of creating a new one
:type session: varies
"""
return self.backend.create_session(session)
def release_session(self, session=None):
"""Release a session context created by create_session.
If session parameter is defined, final session destruction will be deferred to the initial provider of the session. In other words; if create_session was called with a session, release_session should symmetrically be called with the same session.
:param session: Session context.
:type session: varies
"""
return self.backend.release_session(session)

View File

@ -0,0 +1,9 @@
[database]
name =
engine =
driver =
host =
port =
user =
password =
debug = 0

View File

@ -0,0 +1,33 @@
from alembic import op
import sqlalchemy as sa
from chainqueue.db.migrations.default.versions.src.otx import (
upgrade as upgrade_otx,
downgrade as downgrade_otx,
)
from chainqueue.db.migrations.default.versions.src.tx_cache import (
upgrade as upgrade_tx_cache,
downgrade as downgrade_tx_cache,
)
from chainqueue.db.migrations.default.versions.src.otx_state_history import (
upgrade as upgrade_otx_state_history,
downgrade as downgrade_otx_state_history,
)
def chainqueue_upgrade(major=0, minor=0, patch=1):
r0_0_1_u()
def chainqueue_downgrade(major=0, minor=0, patch=1):
r0_0_1_d()
def r0_0_1_u():
upgrade_otx()
upgrade_tx_cache()
upgrade_otx_state_history()
def r0_0_1_d():
downgrade_otx_state_history()
downgrade_tx_cache()
downgrade_otx()

View File

@ -8,32 +8,10 @@ Create Date: 2021-04-02 10:09:11.923949
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2215c497248b'
down_revision = 'c537a0fd8466'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'tx_cache',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=True),
sa.Column('date_created', sa.DateTime, nullable=False),
sa.Column('date_updated', sa.DateTime, nullable=False),
sa.Column('date_checked', sa.DateTime, nullable=False),
sa.Column('source_token_address', sa.String(42), nullable=False),
sa.Column('destination_token_address', sa.String(42), nullable=False),
sa.Column('sender', sa.String(42), nullable=False),
sa.Column('recipient', sa.String(42), nullable=False),
sa.Column('from_value', sa.NUMERIC(), nullable=False),
sa.Column('to_value', sa.NUMERIC(), nullable=True),
# sa.Column('block_number', sa.BIGINT(), nullable=True),
sa.Column('tx_index', sa.Integer, nullable=True),
)
def downgrade():
op.drop_table('tx_cache')
from chainqueue.db.migrations.default.versions.src.tx_cache import upgrade, downgrade

View File

@ -5,26 +5,10 @@ Revises: 2215c497248b
Create Date: 2021-04-02 10:10:58.656139
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3e43847c0717'
down_revision = '2215c497248b'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'otx_state_log',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=False),
sa.Column('date', sa.DateTime, nullable=False),
sa.Column('status', sa.Integer, nullable=False),
)
def downgrade():
op.drop_table('otx_state_log')
from chainqueue.db.migrations.default.versions.src.otx_state_history import upgrade, downgrade

View File

@ -5,32 +5,10 @@ Revises:
Create Date: 2021-04-02 10:04:27.092819
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c537a0fd8466'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'otx',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('date_created', sa.DateTime, nullable=False),
sa.Column('date_updated', sa.DateTime, nullable=False),
sa.Column('nonce', sa.Integer, nullable=False),
sa.Column('tx_hash', sa.Text, nullable=False),
sa.Column('signed_tx', sa.Text, nullable=False),
sa.Column('status', sa.Integer, nullable=False, default=0),
sa.Column('block', sa.Integer),
)
op.create_index('idx_otx_tx', 'otx', ['tx_hash'], unique=True)
def downgrade():
op.drop_index('idx_otx_tx')
op.drop_table('otx')
from chainqueue.db.migrations.default.versions.src.otx import upgrade, downgrade

View File

@ -0,0 +1,21 @@
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
'otx',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('date_created', sa.DateTime, nullable=False),
sa.Column('date_updated', sa.DateTime, nullable=False),
sa.Column('nonce', sa.Integer, nullable=False),
sa.Column('tx_hash', sa.Text, nullable=False),
sa.Column('signed_tx', sa.Text, nullable=False),
sa.Column('status', sa.Integer, nullable=False, default=0),
sa.Column('block', sa.Integer),
)
op.create_index('idx_otx_tx', 'otx', ['tx_hash'], unique=True)
def downgrade():
op.drop_index('idx_otx_tx')
op.drop_table('otx')

View File

@ -0,0 +1,15 @@
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
'otx_state_log',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=False),
sa.Column('date', sa.DateTime, nullable=False),
sa.Column('status', sa.Integer, nullable=False),
)
def downgrade():
op.drop_table('otx_state_log')

View File

@ -0,0 +1,25 @@
# external imports
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
'tx_cache',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=True),
sa.Column('date_created', sa.DateTime, nullable=False),
sa.Column('date_updated', sa.DateTime, nullable=False),
sa.Column('date_checked', sa.DateTime, nullable=False),
sa.Column('source_token_address', sa.String(42), nullable=False),
sa.Column('destination_token_address', sa.String(42), nullable=False),
sa.Column('sender', sa.String(42), nullable=False),
sa.Column('recipient', sa.String(42), nullable=False),
sa.Column('from_value', sa.NUMERIC(), nullable=False),
sa.Column('to_value', sa.NUMERIC(), nullable=True),
# sa.Column('block_number', sa.BIGINT(), nullable=True),
sa.Column('tx_index', sa.Integer, nullable=True),
)
def downgrade():
op.drop_table('tx_cache')

View File

@ -1,55 +0,0 @@
from alembic import op
import sqlalchemy as sa
def chainqueue_upgrade(major=0, minor=0, patch=1):
r0_0_1_u()
def chainqueue_downgrade(major=0, minor=0, patch=1):
r0_0_1_d()
def r0_0_1_u():
op.create_table(
'otx',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('date_created', sa.DateTime, nullable=False),
sa.Column('date_updated', sa.DateTime, nullable=False),
sa.Column('nonce', sa.Integer, nullable=False),
sa.Column('tx_hash', sa.Text, nullable=False),
sa.Column('signed_tx', sa.Text, nullable=False),
sa.Column('status', sa.Integer, nullable=False, default=0),
sa.Column('block', sa.Integer),
)
op.create_index('idx_otx_tx', 'otx', ['tx_hash'], unique=True)
op.create_table(
'otx_state_log',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=False),
sa.Column('date', sa.DateTime, nullable=False),
sa.Column('status', sa.Integer, nullable=False),
)
op.create_table(
'tx_cache',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('otx_id', sa.Integer, sa.ForeignKey('otx.id'), nullable=True),
sa.Column('date_created', sa.DateTime, nullable=False),
sa.Column('date_updated', sa.DateTime, nullable=False),
sa.Column('date_checked', sa.DateTime, nullable=False),
sa.Column('source_token_address', sa.String(42), nullable=False),
sa.Column('destination_token_address', sa.String(42), nullable=False),
sa.Column('sender', sa.String(42), nullable=False),
sa.Column('recipient', sa.String(42), nullable=False),
sa.Column('from_value', sa.NUMERIC(), nullable=False),
sa.Column('to_value', sa.NUMERIC(), nullable=True),
sa.Column('tx_index', sa.Integer, nullable=True),
)
def r0_0_1_d():
op.drop_table('tx_cache')
op.drop_table('otx_state_log')
op.drop_index('idx_otx_tx')
op.drop_table('otx')

View File

@ -119,3 +119,4 @@ class SessionBase(Model):
logg.debug('commit and destroy session {}'.format(session_key))
session.commit()
session.close()
del SessionBase.localsessions[session_key]

View File

@ -71,7 +71,7 @@ class TxCache(SessionBase):
Only manipulates object, does not transaction or commit to backend.
"""
self.date_checked = datetime.datetime.now()
self.date_checked = datetime.datetime.utcnow()
@staticmethod

View File

@ -26,32 +26,14 @@ class StatusBits(enum.IntEnum):
@enum.unique
class StatusEnum(enum.IntEnum):
"""
- Inactive, not finalized. (<0)
* PENDING: The initial state of a newly added transaction record. No action has been performed on this transaction yet.
* SENDFAIL: The transaction was not received by the node.
* RETRY: The transaction is queued for a new send attempt after previously failing.
* READYSEND: The transaction is queued for its first send attempt
* OBSOLETED: A new transaction with the same nonce and higher gas has been sent to network.
* WAITFORGAS: The transaction is on hold pending gas funding.
- Active state: (==0)
* SENT: The transaction has been sent to the mempool.
- Inactive, finalized. (>0)
* FUBAR: Unknown error occurred and transaction is abandoned. Manual intervention needed.
* CANCELLED: The transaction was sent, but was not mined and has disappered from the mempool. This usually follows a transaction being obsoleted.
* OVERRIDDEN: Transaction has been manually overriden.
* REJECTED: The transaction was rejected by the node.
* REVERTED: The transaction was mined, but exception occurred during EVM execution. (Block number will be set)
* SUCCESS: THe transaction was successfully mined. (Block number will be set)
"""Semantic states intended for human consumption
"""
PENDING = 0
SENDFAIL = StatusBits.DEFERRED | StatusBits.LOCAL_ERROR
RETRY = StatusBits.QUEUED | StatusBits.DEFERRED
READYSEND = StatusBits.QUEUED
RESERVED = StatusBits.QUEUED | StatusBits.RESERVED
RESERVED = StatusBits.RESERVED
OBSOLETED = StatusBits.OBSOLETE | StatusBits.IN_NETWORK
@ -105,6 +87,15 @@ def status_str(v, bits_only=False):
def status_bytes(status=0):
"""Serialize a status bit field integer value to bytes.
if invoked without an argument, it will return the serialization of an empty state.
:param status: Status bit field
:type status: number
:returns: Serialized value
:rtype: bytes
"""
return status.to_bytes(8, byteorder='big')
@ -116,6 +107,7 @@ def all_errors():
"""
return StatusBits.LOCAL_ERROR | StatusBits.NODE_ERROR | StatusBits.NETWORK_ERROR | StatusBits.UNKNOWN_ERROR
errors = all_errors()
def is_error_status(v):
"""Check if value is an error state
@ -130,10 +122,24 @@ def is_error_status(v):
__ignore_manual_value = ~StatusBits.MANUAL
def ignore_manual(v):
"""Reset the MANUAL bit from the given status
:param v: Status bit field
:type v: number
:returns: Input state with manual bit removed
:rtype: number
"""
return v & __ignore_manual_value
def is_nascent(v):
"""Check if state is the empty state
:param v: Status bit field
:type v: number
:returns: True if empty
:rtype: bool
"""
return ignore_manual(v) == StatusEnum.PENDING
@ -151,6 +157,9 @@ def is_alive(v):
The contingency of "likely" refers to the case a transaction has been obsoleted after sent to the network, but the network still confirms the obsoleted transaction. The return value of this method will not change as a result of this, BUT the state itself will (as the FINAL bit will be set).
:returns:
:param v: Status bit field
:type v: number
:returns: True if status is not yet finalized
:rtype: bool
"""
return bool(v & dead() == 0)

View File

@ -1,4 +1,6 @@
class ChainQueueException(Exception):
"""Base class for chainqueue related exceptions
"""
pass

View File

@ -1,133 +0,0 @@
# standard imports
import os
import stat
import logging
# external imports
from hexathon import valid as valid_hex
logg = logging.getLogger(__name__)
class HexDir:
def __init__(self, root_path, key_length, levels=2, prefix_length=0):
self.path = root_path
self.key_length = key_length
self.prefix_length = prefix_length
self.entry_length = key_length + prefix_length
self.__levels = levels + 2
fi = None
try:
fi = os.stat(self.path)
self.__verify_directory()
except FileNotFoundError:
HexDir.__prepare_directory(self.path)
self.master_file = os.path.join(self.path, 'master')
@property
def levels(self):
return self.__levels - 2
def add(self, key, content, prefix=b''):
l = len(key)
if l != self.key_length:
raise ValueError('expected key length {}, got {}'.format(self.key_length, l))
l = len(prefix)
if l != self.prefix_length:
raise ValueError('expected prefix length {}, got {}'.format(self.prefix_length, l))
if not isinstance(content, bytes):
raise ValueError('content must be bytes, got {}'.format(type(content).__name__))
if prefix != None and not isinstance(prefix, bytes):
raise ValueError('prefix must be bytes, got {}'.format(type(content).__name__))
key_hex = key.hex()
entry_path = self.to_filepath(key_hex)
c = self.count()
os.makedirs(os.path.dirname(entry_path), exist_ok=True)
f = open(entry_path, 'wb')
f.write(content)
f.close()
f = open(self.master_file, 'ab')
if prefix != None:
f.write(prefix)
f.write(key)
f.close()
logg.info('created new entry {} idx {} in {}'.format(key_hex, c, entry_path))
return c
def count(self):
fi = os.stat(self.master_file)
c = fi.st_size / self.entry_length
r = int(c)
if r != c: # TODO: verify valid for check if evenly divided
raise IndexError('master file not aligned')
return r
def __cursor(self, idx):
return idx * (self.prefix_length + self.key_length)
def set_prefix(self, idx, prefix):
l = len(prefix)
if l != self.prefix_length:
raise ValueError('expected prefix length {}, got {}'.format(self.prefix_length, l))
if not isinstance(prefix, bytes):
raise ValueError('prefix must be bytes, got {}'.format(type(content).__name__))
cursor = self.__cursor(idx)
f = open(self.master_file, 'rb+')
f.seek(cursor)
f.write(prefix)
f.close()
def get(self, idx):
cursor = self.__cursor(idx)
f = open(self.master_file, 'rb')
f.seek(cursor)
prefix = f.read(self.prefix_length)
key = f.read(self.key_length)
f.close()
return (prefix, key)
def to_subpath(self, hx):
lead = ''
for i in range(0, self.__levels, 2):
lead += hx[i:i+2] + '/'
return lead.upper()
def to_dirpath(self, hx):
sub_path = self.to_subpath(hx)
return os.path.join(self.path, sub_path)
def to_filepath(self, hx):
dir_path = self.to_dirpath(hx)
file_path = os.path.join(dir_path, hx.upper())
return file_path
def __verify_directory(self):
#if not stat.S_ISDIR(fi.st_mode):
# raise ValueError('{} is not a directory'.format(self.path))
f = opendir(self.path)
f.close()
return True
@staticmethod
def __prepare_directory(path):
os.makedirs(path, exist_ok=True)
state_file = os.path.join(path, 'master')
f = open(state_file, 'w')
f.close()

View File

@ -1,5 +1,6 @@
# standard imports
import datetime
import logging
# external imports
from hexathon import strip_0x
@ -7,6 +8,8 @@ from hexathon import strip_0x
# local imports
from chainqueue.enum import StatusEnum
logg = logging.getLogger(__name__)
class DefaultApplier:
@ -22,6 +25,7 @@ class Entry:
self.signed_tx = strip_0x(signed_tx)
self.status = StatusEnum.PENDING
self.applier = applier
self.applier.add(bytes.fromhex(tx_hash), bytes.fromhex(signed_tx))

View File

@ -14,7 +14,6 @@ logg = logging.getLogger(__name__)
class FsQueueBackend:
def add(self, label, content, prefix):
raise NotImplementedError()
@ -52,7 +51,7 @@ class FsQueue:
def add(self, key, value):
prefix = status_bytes()
c = self.backend.add(key, value, prefix=prefix)
(c, entry_location) = self.backend.add(key, value, prefix=prefix)
key_hex = key.hex()
entry_path = os.path.join(self.index_path, key_hex)

144
chainqueue/runnable/list.py Normal file
View File

@ -0,0 +1,144 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# standard imports
import os
import logging
import sys
# external imports
from hexathon import add_0x
import chainlib.cli
from chainlib.chain import ChainSpec
from crypto_dev_signer.eth.signer import ReferenceSigner as EIP155Signer
# local imports
from chainqueue.enum import (
StatusBits,
all_errors,
is_alive,
is_error_status,
status_str,
)
logging.basicConfig(level=logging.WARNING)
logg = logging.getLogger()
script_dir = os.path.dirname(os.path.realpath(__file__))
config_dir = os.path.join(script_dir, '..', 'data', 'config')
arg_flags = chainlib.cli.argflag_std_base | chainlib.cli.Flag.CHAIN_SPEC
argparser = chainlib.cli.ArgumentParser(arg_flags)
argparser.add_argument('--backend', type=str, default='sql', help='Backend to use (currently only "sql")')
argparser.add_argument('--start', type=str, help='Oldest transaction hash to include in results')
argparser.add_argument('--end', type=str, help='Newest transaction hash to include in results')
argparser.add_argument('--error', action='store_true', help='Only show transactions which have error state')
argparser.add_argument('--pending', action='store_true', help='Omit finalized transactions')
argparser.add_argument('--status-mask', type=int, dest='status_mask', help='Manually specify status bitmask value to match (overrides --error and --pending)')
argparser.add_argument('--summary', action='store_true', help='output summary for each status category')
argparser.add_positional('address', type=str, help='Ethereum address of recipient')
args = argparser.parse_args()
extra_args = {
'address': None,
'backend': None,
'start': None,
'end': None,
'error': None,
'pending': None,
'status_mask': None,
'summary': None,
}
config = chainlib.cli.Config.from_args(args, arg_flags, extra_args=extra_args, base_config_dir=config_dir)
chain_spec = ChainSpec.from_chain_str(config.get('CHAIN_SPEC'))
status_mask = config.get('_STATUS_MASK', None)
not_status_mask = None
if status_mask == None:
if config.get('_ERROR'):
status_mask = all_errors()
if config.get('_PENDING'):
not_status_mask = StatusBits.FINAL
tx_getter = None
session_method = None
if config.get('_BACKEND') == 'sql':
from chainqueue.sql.query import get_account_tx as tx_lister
from chainqueue.sql.query import get_tx_cache as tx_getter
from chainqueue.runnable.sql import setup_backend
from chainqueue.db.models.base import SessionBase
setup_backend(config, debug=config.true('DATABASE_DEBUG'))
session_method = SessionBase.create_session
else:
raise NotImplementedError('backend {} not implemented'.format(config.get('_BACKEND')))
class Outputter:
def __init__(self, chain_spec, writer, session_method=None):
self.writer = writer
self.chain_spec = chain_spec
self.chain_spec_str = str(chain_spec)
self.session = None
if session_method != None:
self.session = session_method()
self.results = {
'pending_error': 0,
'final_error': 0,
'pending': 0,
'final': 0,
}
def __del__(self):
if self.session != None:
self.session.close()
def add(self, tx_hash):
tx = tx_getter(self.chain_spec, tx_hash, session=self.session)
category = None
if is_alive(tx['status_code']):
category = 'pending'
else:
category = 'final'
self.results[category] += 1
if is_error_status(tx['status_code']):
logg.debug('registered {} as {} with error'.format(tx_hash, category))
self.results[category + '_error'] += 1
else:
logg.debug('registered {} as {}'.format(tx_hash, category))
def decode_summary(self):
self.writer.write('pending\t{}\t{}\n'.format(self.results['pending'], self.results['pending_error']))
self.writer.write('final\t{}\t{}\n'.format(self.results['final'], self.results['final_error']))
self.writer.write('total\t{}\t{}\n'.format(self.results['final'] + self.results['pending'], self.results['final_error'] + self.results['pending_error']))
def decode_single(self, tx_hash):
tx = tx_getter(self.chain_spec, tx_hash, session=self.session)
status = tx['status']
if config.get('_RAW'):
status = status_str(tx['status_code'], bits_only=True)
self.writer.write('{}\t{}\t{}\t{}\n'.format(self.chain_spec_str, add_0x(tx_hash), status, tx['status_code']))
def main():
since = config.get('_START', None)
if since != None:
since = add_0x(since)
until = config.get('_END', None)
if until != None:
until = add_0x(until)
txs = tx_lister(chain_spec, config.get('_ADDRESS'), since=since, until=until, status=status_mask, not_status=not_status_mask)
outputter = Outputter(chain_spec, sys.stdout, session_method=session_method)
if config.get('_SUMMARY'):
for k in txs.keys():
outputter.add(k)
outputter.decode_summary()
else:
for k in txs.keys():
outputter.decode_single(k)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,14 @@
# standard imports
import logging
# local imports
from chainqueue.db.models.base import SessionBase
from chainqueue.db import dsn_from_config
logg = logging.getLogger(__name__)
def setup_backend(config, debug=False):
dsn = dsn_from_config(config)
logg.debug('dsn {}'.format(dsn))
SessionBase.connect(dsn, debug=debug)

View File

@ -1,22 +1,33 @@
# standard imports
import logging
import urllib.error
# external imports
from sqlalchemy.exc import (
IntegrityError,
)
from chainlib.error import JSONRPCException
from hexathon import (
add_0x,
strip_0x,
uniform as hex_uniform,
)
# local imports
from chainqueue.sql.tx import create as queue_create
from chainqueue.db.models.base import SessionBase
from chainqueue.db.models.tx import TxCache
from chainqueue.sql.query import get_upcoming_tx
from chainqueue.sql.query import (
get_upcoming_tx,
get_tx as backend_get_tx,
)
from chainqueue.sql.state import (
set_ready,
set_reserved,
set_sent,
set_fubar,
)
from chainqueue.sql.tx import cache_tx_dict
logg = logging.getLogger(__name__)
@ -38,15 +49,20 @@ class SQLBackend:
def cache(self, tx, session=None):
txc = TxCache(tx['hash'], tx['from'], tx['to'], tx['source_token'], tx['destination_token'], tx['from_value'], tx['to_value'], session=session)
session.add(txc)
session.flush()
logg.debug('cache {}'.format(txc))
(tx, txc_id) = cache_tx_dict(tx, session=session)
logg.debug('cached {} db insert id {}'.format(tx, txc_id))
return 0
def get(self, chain_spec, typ, decoder):
txs = get_upcoming_tx(chain_spec, typ, decoder=decoder)
def get_tx(self, chain_spec, tx_hash, session=None):
return backend_get_tx(chain_spec, tx_hash, session=session)
def get(self, chain_spec, decoder, session=None, requeue=False, *args, **kwargs):
txs = get_upcoming_tx(chain_spec, status=kwargs.get('status'), decoder=decoder, not_status=kwargs.get('not_status', 0), recipient=kwargs.get('recipient'), before=kwargs.get('before'), limit=kwargs.get('limit', 0))
if requeue:
for tx_hash in txs.keys():
set_ready(chain_spec, tx_hash, session=session)
return txs
@ -57,15 +73,25 @@ class SQLBackend:
try:
rpc.do(payload)
r = 0
except ConnectionError:
except ConnectionError as e:
logg.error('dispatch {} connection error {}'.format(tx_hash, e))
fail = True
except urllib.error.URLError as e:
logg.error('dispatch {} urllib error {}'.format(tx_hash, e))
fail = True
except JSONRPCException as e:
logg.error('error! {}'.format(e))
logg.exception('error! {}'.format(e))
set_fubar(chain_spec, tx_hash, session=session)
raise e
logg.debug('foo')
set_sent(chain_spec, tx_hash, fail=fail, session=session)
return r
def create_session(self):
return SessionBase.create_session()
def create_session(self, session=None):
return SessionBase.bind_session(session=session)
def release_session(self, session):
return SessionBase.release_session(session=session)

View File

@ -11,6 +11,7 @@ from sqlalchemy import func
from hexathon import (
add_0x,
strip_0x,
uniform as hex_uniform,
)
# local imports
@ -26,16 +27,21 @@ from chainqueue.db.enum import (
)
from chainqueue.error import (
NotLocalTxError,
CacheIntegrityError,
)
logg = logging.getLogger().getChild(__name__)
logg = logging.getLogger(__name__)
def get_tx_cache(chain_spec, tx_hash, session=None):
"""Returns an aggregate dictionary of outgoing transaction data and metadata
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param tx_hash: Transaction hash of record to modify
:type tx_hash: str, 0x-hex
:param session: Backend state integrity session
:type session: varies
:raises NotLocalTxError: If transaction not found in queue.
:returns: Transaction data
:rtype: dict
@ -81,8 +87,12 @@ def get_tx_cache(chain_spec, tx_hash, session=None):
def get_tx(chain_spec, tx_hash, session=None):
"""Retrieve a transaction queue record by transaction hash
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param tx_hash: Transaction hash of record to modify
:type tx_hash: str, 0x-hex
:param session: Backend state integrity session
:type session: varies
:raises NotLocalTxError: If transaction not found in queue.
:returns: nonce, address and signed_tx (raw signed transaction)
:rtype: dict
@ -107,26 +117,35 @@ def get_tx(chain_spec, tx_hash, session=None):
def get_nonce_tx_cache(chain_spec, nonce, sender, decoder=None, session=None):
"""Retrieve all transactions for address with specified nonce
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param nonce: Nonce
:type nonce: number
:param address: Ethereum address
:type address: str, 0x-hex
:param sender: Ethereum address
:type sender: str, 0x-hex
:param decoder: Transaction decoder
:type decoder: TODO - define transaction decoder
:param session: Backend state integrity session
:type session: varies
:returns: Transactions
:rtype: dict, with transaction hash as key, signed raw transaction as value
"""
sender = add_0x(hex_uniform(strip_0x(sender)))
session = SessionBase.bind_session(session)
q = session.query(Otx)
q = q.join(TxCache)
q = q.filter(TxCache.sender==sender)
q = q.filter(Otx.nonce==nonce)
txs = {}
for r in q.all():
tx_signed_bytes = bytes.fromhex(r.signed_tx)
if decoder != None:
tx = decoder(tx_signed_bytes, chain_spec)
if sender != None and tx['from'] != sender:
raise IntegrityError('Cache sender {} does not match sender in tx {} using decoder {}'.format(sender, r.tx_hash, str(decoder)))
tx_from = add_0x(hex_uniform(strip_0x(tx['from'])))
if sender != None and tx_from != sender:
raise CacheIntegrityError('Cache sender {} does not match sender {} in tx {} using decoder {}'.format(sender, tx_from, r.tx_hash, str(decoder)))
txs[r.tx_hash] = r.signed_tx
SessionBase.release_session(session)
@ -137,12 +156,18 @@ def get_nonce_tx_cache(chain_spec, nonce, sender, decoder=None, session=None):
def get_paused_tx_cache(chain_spec, status=None, sender=None, session=None, decoder=None):
"""Returns not finalized transactions that have been attempted sent without success.
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param status: If set, will return transactions with this local queue status only
:type status: cic_eth.db.enum.StatusEnum
:param recipient: Recipient address to return transactions for
:type recipient: str, 0x-hex
:param chain_id: Numeric chain id to use to parse signed transaction data
:type chain_id: number
:param decoder: Transaction decoder
:type decoder: TODO - define transaction decoder
:param session: Backend state integrity session
:type session: varies
:raises ValueError: Status is finalized, sent or never attempted sent
:returns: Transactions
:rtype: dict, with transaction hash as key, signed raw transaction as value
@ -161,6 +186,7 @@ def get_paused_tx_cache(chain_spec, status=None, sender=None, session=None, deco
q = q.filter(not_(Otx.status.op('&')(StatusBits.IN_NETWORK.value)>0))
if sender != None:
sender = add_0x(hex_uniform(strip_0x(sender)))
q = q.filter(TxCache.sender==sender)
txs = {}
@ -170,8 +196,9 @@ def get_paused_tx_cache(chain_spec, status=None, sender=None, session=None, deco
tx_signed_bytes = bytes.fromhex(r.signed_tx)
if decoder != None:
tx = decoder(tx_signed_bytes, chain_spec)
if sender != None and tx['from'] != sender:
raise IntegrityError('Cache sender {} does not match sender in tx {} using decoder {}'.format(sender, r.tx_hash, str(decoder)))
tx_from = add_0x(hex_uniform(strip_0x(tx['from'])))
if sender != None and tx_from != sender:
raise CacheIntegrityError('Cache sender {} does not match sender {} in tx {} using decoder {}'.format(sender, tx_from, r.tx_hash, str(decoder)))
gas += tx['gas'] * tx['gasPrice']
txs[r.tx_hash] = r.signed_tx
@ -184,12 +211,20 @@ def get_paused_tx_cache(chain_spec, status=None, sender=None, session=None, deco
def get_status_tx_cache(chain_spec, status, not_status=None, before=None, exact=False, limit=0, session=None, decoder=None):
"""Retrieve transaction with a specific queue status.
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param status: Status to match transactions with
:type status: str
:param before: If set, return only transactions older than the timestamp
:type status: datetime.dateTime
:type before: datetime.dateTime
:param exact: If set, will match exact status value. If not set, will match any of the status bits set
:type exact: bool
:param limit: Limit amount of returned transactions
:type limit: number
:param decoder: Transaction decoder
:type decoder: TODO - define transaction decoder
:param session: Backend state integrity session
:type session: varies
:returns: Transactions
:rtype: list of cic_eth.db.models.otx.Otx
"""
@ -223,14 +258,22 @@ def get_upcoming_tx(chain_spec, status=StatusEnum.READYSEND, not_status=None, re
(TODO) Will not return any rows if LockEnum.SEND bit in Lock is set for zero address.
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param status: Defines the status used to filter as upcoming.
:type status: cic_eth.db.enum.StatusEnum
:param not_status: Invalidates any matches matching one of the given bits
:type not_status: cic_eth.db.enum.StatusEnum
:param recipient: Ethereum address of recipient to return transaction for
:type recipient: str, 0x-hex
:param before: Only return transactions if their modification date is older than the given timestamp
:type before: datetime.datetime
:param chain_id: Chain id to use to parse signed transaction data
:type chain_id: number
:param limit: Limit amount of returned transactions
:type limit: number
:param decoder: Transaction decoder
:type decoder: TODO - define transaction decoder
:param session: Backend state integrity session
:type session: varies
:raises ValueError: Status is finalized, sent or never attempted sent
:returns: Transactions
:rtype: dict, with transaction hash as key, signed raw transaction as value
@ -285,7 +328,7 @@ def get_upcoming_tx(chain_spec, status=StatusEnum.READYSEND, not_status=None, re
q = q.filter(TxCache.otx_id==o.id)
o = q.first()
o.date_checked = datetime.datetime.now()
o.date_checked = datetime.datetime.utcnow()
session.add(o)
session.commit()
@ -298,17 +341,68 @@ def get_upcoming_tx(chain_spec, status=StatusEnum.READYSEND, not_status=None, re
return txs
def get_account_tx(chain_spec, address, as_sender=True, as_recipient=True, counterpart=None, session=None):
def sql_range_filter(session, criteria=None):
"""Convert an arbitrary type to a sql query range
:param session: Backend state integrity session
:type session: varies
:param criteria: Range criteria
:type criteria: any
:raises NotLocalTxError: If criteria is string, transaction hash does not exist in backend
:rtype: tuple
:returns: type string identifier, value
"""
boundary = None
if criteria == None:
return None
if isinstance(criteria, str):
q = session.query(Otx)
q = q.filter(Otx.tx_hash==strip_0x(criteria))
r = q.first()
if r == None:
raise NotLocalTxError('unknown tx hash as bound criteria specified: {}'.format(criteria))
boundary = ('id', r.id,)
elif isinstance(criteria, int):
boundary = ('id', criteria,)
elif isinstance(criteria, datetime.datetime):
boundary = ('date', criteria,)
return boundary
def get_account_tx(chain_spec, address, as_sender=True, as_recipient=True, counterpart=None, since=None, until=None, status=None, not_status=None, status_target=None, session=None):
"""Returns all local queue transactions for a given Ethereum address
The since parameter effect depends on its type. Results are returned inclusive of the given parameter condition.
* str - transaction hash; all transactions added after the given hash
* int - all transactions after the given db insert id
* datetime.datetime - all transactions added since the given datetime
:param chain_spec: Chain spec for transaction network
:type chain_spec: chainlib.chain.ChainSpec
:param address: Ethereum address
:type address: str, 0x-hex
:param as_sender: If False, will omit transactions where address is sender
:type as_sender: bool
:param as_sender: If False, will omit transactions where address is recipient
:type as_sender: bool
:param as_recipient: If False, will omit transactions where address is recipient
:type as_recipient: bool
:param counterpart: Only return transactions where this Ethereum address is the other end of the transaction (not in use)
:type counterpart: str, 0x-hex
:param since: Only include transactions submitted before this datetime
:type since: datetime
:param until: Only include transactions submitted before this datetime
:type until: datetime
:param status: Only include transactions where the given status bits are set
:type status: chainqueue.enum.StatusEnum
:param not_status: Only include transactions where the given status bits are not set
:type not_status: chainqueue.enum.StatusEnum
:param status_target: Only include transaction where the status argument is exact match
:type status_target: chainqueue.enum.StatusEnum
:param session: Backend state integrity session
:type session: varies
:raises ValueError: If address is set to be neither sender nor recipient
:returns: Transactions
:rtype: dict, with transaction hash as key, signed raw transaction as value
@ -319,6 +413,15 @@ def get_account_tx(chain_spec, address, as_sender=True, as_recipient=True, count
txs = {}
session = SessionBase.bind_session(session)
address = add_0x(hex_uniform(strip_0x(address)))
try:
filter_offset = sql_range_filter(session, criteria=since)
filter_limit = sql_range_filter(session, criteria=until)
except NotLocalTxError as e:
logg.error('query build failed: {}'.format(e))
return {}
q = session.query(Otx)
q = q.join(TxCache)
if as_sender and as_recipient:
@ -327,7 +430,28 @@ def get_account_tx(chain_spec, address, as_sender=True, as_recipient=True, count
q = q.filter(TxCache.sender==address)
else:
q = q.filter(TxCache.recipient==address)
q = q.order_by(Otx.nonce.asc(), Otx.date_created.asc())
if filter_offset != None:
if filter_offset[0] == 'id':
q = q.filter(Otx.id>=filter_offset[1])
elif filter_offset[0] == 'date':
q = q.filter(Otx.date_created>=filter_offset[1])
if filter_limit != None:
if filter_limit[0] == 'id':
q = q.filter(Otx.id<=filter_limit[1])
elif filter_limit[0] == 'date':
q = q.filter(Otx.date_created<=filter_limit[1])
if status != None:
if status_target == None:
status_target = status
q = q.filter(Otx.status.op('&')(status)==status_target)
if not_status != None:
q = q.filter(Otx.status.op('&')(not_status)==0)
q = q.order_by(Otx.nonce.asc(), Otx.date_created.asc())
results = q.all()
for r in results: