mirror of
https://github.com/Blockstream/satellite-api.git
synced 2025-03-13 11:35:20 +01:00
Previously, the /orders/:state endpoint only returned the "pending", "queued", and "sent" states. This patch adds the following new states: "paid", "transmitting", "confirming", "rx-pending", "retransmitting", and "received". These are mapped directly to the order status, except for the "rx-pending" and "retransmitting" states, which are inferred based on other information. The "rx-pending" state returns orders with sent status only, as opposed to the call to /orders/sent, which returns orders with sent and received status. The "retransmitting" state returns any order with entries on the retransmission database.
446 lines
17 KiB
Python
446 lines
17 KiB
Python
from datetime import datetime, timedelta
|
|
from http import HTTPStatus
|
|
from hashlib import sha256
|
|
import json
|
|
import os
|
|
from uuid import uuid4
|
|
|
|
from flask import current_app, request, send_file
|
|
from flask_restful import Resource
|
|
from marshmallow import ValidationError
|
|
from sqlalchemy import and_, or_
|
|
|
|
from constants import CHANNEL_INFO, ORDER_FETCH_STATES, OrderStatus
|
|
from database import db
|
|
from error import get_http_error_resp
|
|
from invoice_helpers import new_invoice, pay_invoice
|
|
from models import Order, RxConfirmation, TxConfirmation, TxRetry
|
|
from regions import region_number_list_to_code
|
|
from schemas import admin_order_schema, order_schema, orders_schema,\
|
|
order_upload_req_schema, order_bump_schema,\
|
|
rx_confirmation_schema, tx_confirmation_schema
|
|
import bidding
|
|
import constants
|
|
import order_helpers
|
|
import transmitter
|
|
|
|
SHA256_BLOCK_SIZE = 65536
|
|
|
|
|
|
def sha256_checksum(filename, block_size=SHA256_BLOCK_SIZE):
|
|
msg_hash = sha256()
|
|
with open(filename, 'rb') as f:
|
|
for block in iter(lambda: f.read(block_size), b''):
|
|
msg_hash.update(block)
|
|
return msg_hash.hexdigest()
|
|
|
|
|
|
class OrderResource(Resource):
|
|
|
|
def get(self, uuid):
|
|
admin_mode = request.path.startswith("/admin/")
|
|
|
|
success, order_or_error = order_helpers.get_and_authenticate_order(
|
|
uuid, request.form, request.args)
|
|
if not success:
|
|
return order_or_error
|
|
order = order_or_error
|
|
|
|
if not admin_mode and 'get' not in \
|
|
constants.CHANNEL_INFO[order.channel].user_permissions:
|
|
return get_http_error_resp('ORDER_CHANNEL_UNAUTHORIZED_OP',
|
|
order.channel)
|
|
schema = admin_order_schema if admin_mode else order_schema
|
|
return schema.dump(order)
|
|
|
|
def delete(self, uuid):
|
|
admin_mode = request.path.startswith("/admin/")
|
|
|
|
success, order_or_error = order_helpers.get_and_authenticate_order(
|
|
uuid, request.form, request.args)
|
|
if not success:
|
|
return order_or_error
|
|
order = order_or_error
|
|
|
|
if not admin_mode and 'delete' not in \
|
|
constants.CHANNEL_INFO[order.channel].user_permissions:
|
|
return get_http_error_resp('ORDER_CHANNEL_UNAUTHORIZED_OP',
|
|
order.channel)
|
|
|
|
if order.status != OrderStatus.pending.value and\
|
|
order.status != OrderStatus.paid.value:
|
|
return get_http_error_resp('ORDER_CANCELLATION_ERROR',
|
|
OrderStatus(order.status).name)
|
|
|
|
message_file = os.path.join(constants.MSG_STORE_PATH, order.uuid)
|
|
if os.path.exists(message_file):
|
|
os.remove(message_file)
|
|
order.status = OrderStatus.cancelled.value
|
|
order.cancelled_at = datetime.utcnow()
|
|
db.session.commit()
|
|
return {"message": "order cancelled"}
|
|
|
|
|
|
class OrderUploadResource(Resource):
|
|
|
|
def post(self):
|
|
admin_mode = request.path.startswith("/admin/")
|
|
|
|
try:
|
|
args = order_upload_req_schema.load(request.form)
|
|
except ValidationError as error:
|
|
return error.messages, HTTPStatus.BAD_REQUEST
|
|
|
|
has_msg = 'message' in args
|
|
has_file = 'file' in request.files
|
|
|
|
channel = args['channel']
|
|
if not admin_mode and 'post' not in \
|
|
constants.CHANNEL_INFO[channel].user_permissions:
|
|
return get_http_error_resp('ORDER_CHANNEL_UNAUTHORIZED_OP',
|
|
channel)
|
|
requires_payment = CHANNEL_INFO[channel].requires_payment
|
|
|
|
if (has_msg and has_file):
|
|
return "Choose message or file", HTTPStatus.BAD_REQUEST
|
|
|
|
if (not (has_msg or has_file)):
|
|
return get_http_error_resp('MESSAGE_MISSING')
|
|
|
|
uuid = str(uuid4())
|
|
filepath = os.path.join(constants.MSG_STORE_PATH, uuid)
|
|
|
|
if (has_msg):
|
|
with open(filepath, 'w') as fd:
|
|
fd.write(args['message'])
|
|
else:
|
|
file = request.files['file']
|
|
file.save(filepath)
|
|
|
|
msg_size = os.stat(filepath).st_size
|
|
|
|
if (msg_size < constants.MIN_MESSAGE_SIZE):
|
|
os.remove(filepath)
|
|
return get_http_error_resp('MESSAGE_FILE_TOO_SMALL',
|
|
constants.MIN_MESSAGE_SIZE)
|
|
|
|
if (msg_size > constants.CHANNEL_INFO[channel].max_msg_size):
|
|
os.remove(filepath)
|
|
return get_http_error_resp(
|
|
'MESSAGE_FILE_TOO_LARGE',
|
|
constants.CHANNEL_INFO[channel].max_msg_size / (2**20))
|
|
|
|
bid = int(args.get('bid')) if requires_payment else 0
|
|
if (requires_payment and not bidding.validate_bid(msg_size, bid)):
|
|
os.remove(filepath)
|
|
min_bid = bidding.get_min_bid(msg_size)
|
|
return get_http_error_resp('BID_TOO_SMALL', min_bid)
|
|
|
|
msg_digest = sha256_checksum(filepath)
|
|
starting_state = OrderStatus.pending.value if requires_payment \
|
|
else OrderStatus.paid.value
|
|
new_order = Order(uuid=uuid,
|
|
unpaid_bid=bid,
|
|
message_size=msg_size,
|
|
message_digest=msg_digest,
|
|
status=starting_state,
|
|
channel=channel)
|
|
|
|
if requires_payment:
|
|
success, invoice = new_invoice(new_order, bid)
|
|
if not success:
|
|
return invoice
|
|
new_order.invoices.append(invoice)
|
|
|
|
if 'regions' in args:
|
|
regions_in_request = json.loads(args['regions'])
|
|
new_order.region_code = region_number_list_to_code(
|
|
regions_in_request)
|
|
|
|
db.session.add(new_order)
|
|
db.session.commit()
|
|
|
|
if constants.FORCE_PAYMENT and requires_payment:
|
|
current_app.logger.info('force payment of the invoice')
|
|
pay_invoice(invoice)
|
|
transmitter.tx_start(new_order.channel)
|
|
elif not requires_payment:
|
|
transmitter.tx_start(new_order.channel)
|
|
|
|
# Return the invoice only if the channel requires payment for orders
|
|
resp = {
|
|
'auth_token': order_helpers.compute_auth_token(uuid),
|
|
'uuid': uuid
|
|
}
|
|
if requires_payment:
|
|
resp['lightning_invoice'] = json.loads(invoice.invoice)
|
|
|
|
return resp
|
|
|
|
|
|
class BumpOrderResource(Resource):
|
|
|
|
def post(self, uuid):
|
|
query_args = request.args
|
|
try:
|
|
form_args = order_bump_schema.load(request.form)
|
|
except ValidationError as error:
|
|
return error.messages, HTTPStatus.BAD_REQUEST
|
|
|
|
success, order_or_error = order_helpers.get_and_authenticate_order(
|
|
uuid, form_args, query_args)
|
|
if not success:
|
|
return order_or_error
|
|
order = order_or_error
|
|
|
|
if not CHANNEL_INFO[order.channel].requires_payment:
|
|
return get_http_error_resp('ORDER_CHANNEL_UNAUTHORIZED_OP',
|
|
order.channel)
|
|
|
|
if order.status != OrderStatus.pending.value and\
|
|
order.status != OrderStatus.paid.value:
|
|
return get_http_error_resp('ORDER_BUMP_ERROR',
|
|
OrderStatus(order.status).name)
|
|
|
|
success, invoice = new_invoice(order, form_args['bid_increase'])
|
|
if not success:
|
|
return invoice
|
|
|
|
order.invoices.append(invoice)
|
|
order_helpers.adjust_bids(order)
|
|
db.session.commit()
|
|
|
|
return {
|
|
'auth_token': order_helpers.compute_auth_token(uuid),
|
|
'lightning_invoice': json.loads(invoice.invoice)
|
|
}
|
|
|
|
|
|
class OrdersResource(Resource):
|
|
|
|
def get(self, state):
|
|
admin_mode = request.path.startswith("/admin/")
|
|
if state not in ORDER_FETCH_STATES:
|
|
return {
|
|
state: [
|
|
f'The requested queue of {state} orders\
|
|
does not exist'
|
|
]
|
|
}, HTTPStatus.NOT_FOUND
|
|
|
|
try:
|
|
args = orders_schema.load(request.args)
|
|
except ValidationError as error:
|
|
return error.messages, HTTPStatus.BAD_REQUEST
|
|
|
|
if 'before' not in args and 'before_delta' not in args:
|
|
# Set the "before" date to the near future (e.g., 5 secs ahead) to
|
|
# effectively disable the filtering.
|
|
_before = datetime.utcnow() + timedelta(seconds=5)
|
|
elif 'before' in args:
|
|
_before = args['before']
|
|
else:
|
|
_before = datetime.utcnow() - args['before_delta']
|
|
|
|
if 'after' not in args and 'after_delta' not in args:
|
|
# Set the "after" date to the lowest possible datetime to
|
|
# effectively disable the filtering.
|
|
_after = datetime(1, 1, 1)
|
|
elif 'after' in args:
|
|
_after = args['after']
|
|
else:
|
|
_after = datetime.utcnow() - args['after_delta']
|
|
|
|
before = db.func.datetime(_before)
|
|
after = db.func.datetime(_after)
|
|
limit = args['limit']
|
|
channel = args['channel']
|
|
|
|
if not admin_mode and 'get' not in \
|
|
constants.CHANNEL_INFO[channel].user_permissions:
|
|
return get_http_error_resp('ORDER_CHANNEL_UNAUTHORIZED_OP',
|
|
channel)
|
|
|
|
if state in ['pending', 'paid']:
|
|
condition = Order.status == OrderStatus[state].value
|
|
time_field = Order.created_at
|
|
sort_field = Order.created_at
|
|
elif state in ['transmitting', 'confirming']:
|
|
condition = Order.status == OrderStatus[state].value
|
|
time_field = Order.started_transmission_at
|
|
sort_field = Order.started_transmission_at
|
|
elif state == 'queued':
|
|
condition = or_(Order.status == OrderStatus.transmitting.value,
|
|
Order.status == OrderStatus.confirming.value,
|
|
Order.status == OrderStatus.paid.value)
|
|
time_field = Order.created_at
|
|
sort_field = Order.bid_per_byte
|
|
elif state == 'sent':
|
|
# For backwards compatibility, the "sent" queue returns orders in
|
|
# both sent and received state. Namely, any order with an
|
|
# "ended_transmission_at" timestamp. A new special queue named
|
|
# "rx-pending" can be used to fetch orders in "sent" state only
|
|
# (not yet in "received" state).
|
|
condition = Order.ended_transmission_at.isnot(None)
|
|
time_field = Order.ended_transmission_at
|
|
sort_field = Order.ended_transmission_at
|
|
elif state == 'received':
|
|
condition = Order.status == OrderStatus[state].value
|
|
time_field = Order.ended_transmission_at
|
|
sort_field = Order.ended_transmission_at
|
|
elif state == 'rx-pending':
|
|
condition = Order.status == OrderStatus.sent.value
|
|
time_field = Order.ended_transmission_at
|
|
sort_field = Order.ended_transmission_at
|
|
|
|
if state == 'retransmitting':
|
|
# Only the retransmitting state needs a different query (joining
|
|
# the Order and TxRetry tables). The other states use the same
|
|
# query with different conditions, time and sort fields.
|
|
condition = Order.id == TxRetry.order_id
|
|
time_field = Order.started_transmission_at
|
|
sort_field = Order.started_transmission_at
|
|
res = db.session.query(Order, TxRetry).filter(
|
|
and_(Order.channel == channel, condition)).\
|
|
filter(and_(db.func.datetime(time_field) < before,
|
|
db.func.datetime(time_field) > after)).\
|
|
order_by(sort_field.desc()).\
|
|
limit(limit)
|
|
orders = [x[0] for x in res]
|
|
else:
|
|
orders = Order.query.filter(and_(Order.channel == channel,
|
|
condition)).\
|
|
filter(and_(db.func.datetime(time_field) < before,
|
|
db.func.datetime(time_field) > after)).\
|
|
order_by(sort_field.desc()).\
|
|
limit(limit)
|
|
|
|
return [order_schema.dump(order) for order in orders]
|
|
|
|
|
|
class GetMessageResource(Resource):
|
|
|
|
def get(self, uuid):
|
|
order = Order.query.filter_by(uuid=uuid).filter(
|
|
or_(Order.status == OrderStatus.sent.value,
|
|
Order.status == OrderStatus.transmitting.value,
|
|
Order.status == OrderStatus.confirming.value)).first()
|
|
if not order:
|
|
return get_http_error_resp('ORDER_NOT_FOUND', uuid)
|
|
|
|
message_path = os.path.join(constants.MSG_STORE_PATH, uuid)
|
|
return send_file(message_path,
|
|
mimetype='application/json',
|
|
as_attachment=True,
|
|
add_etags=False)
|
|
|
|
|
|
class GetMessageBySeqNumResource(Resource):
|
|
|
|
def get(self, tx_seq_num):
|
|
admin_mode = request.path.startswith("/admin/")
|
|
|
|
order = Order.query.filter_by(tx_seq_num=tx_seq_num).filter(
|
|
or_(Order.status == OrderStatus.sent.value,
|
|
Order.status == OrderStatus.transmitting.value,
|
|
Order.status == OrderStatus.confirming.value,
|
|
Order.status == OrderStatus.received.value)).first()
|
|
if not order:
|
|
return get_http_error_resp('SEQUENCE_NUMBER_NOT_FOUND', tx_seq_num)
|
|
|
|
if not admin_mode and 'get' not in \
|
|
constants.CHANNEL_INFO[order.channel].user_permissions:
|
|
return get_http_error_resp('ORDER_CHANNEL_UNAUTHORIZED_OP',
|
|
order.channel)
|
|
|
|
message_path = os.path.join(constants.MSG_STORE_PATH, order.uuid)
|
|
return send_file(message_path,
|
|
mimetype='application/json',
|
|
as_attachment=True,
|
|
add_etags=False)
|
|
|
|
|
|
class TxConfirmationResource(Resource):
|
|
|
|
def post(self, tx_seq_num):
|
|
args = request.form
|
|
errors = tx_confirmation_schema.validate(args)
|
|
|
|
if errors:
|
|
return errors, HTTPStatus.BAD_REQUEST
|
|
|
|
# Find order by sequence number. Note only orders in the following
|
|
# states have a sequence number: transmitting, confirming, sent or
|
|
# received. In contrast, pending or paid orders do not have a sequence
|
|
# number. Hence, the following query implicitly ensures the order is in
|
|
# a reasonable state to receive a Tx confirmation, even if it's a
|
|
# repeated confirmation (e.g., if the order is already received).
|
|
order = Order.query.filter_by(tx_seq_num=tx_seq_num).first()
|
|
if not order:
|
|
return get_http_error_resp('SEQUENCE_NUMBER_NOT_FOUND', tx_seq_num)
|
|
|
|
# A Tx confirmation indicates that at least one Tx host finished
|
|
# transmitting the order. At this point, the other Tx hosts should
|
|
# complete the order soon. In the meantime, change the order state
|
|
# from transmitting to confirming so that other pending orders
|
|
# can be unblocked.
|
|
last_status = order.status
|
|
if order.status == OrderStatus.transmitting.value:
|
|
order.status = OrderStatus.confirming.value
|
|
db.session.commit()
|
|
|
|
regions_in_request = json.loads(args['regions'])
|
|
for region_number in regions_in_request:
|
|
order_helpers.add_confirmation_if_not_present(
|
|
TxConfirmation, order, region_number)
|
|
|
|
# Check whether the order is in "sent" or "received" state already. In
|
|
# the positive case, end the current transmission to start a new one.
|
|
if order_helpers.sent_or_received_criteria_met(order):
|
|
transmitter.tx_end(order)
|
|
|
|
# If the order status is still "confirming" at this point, it can be
|
|
# inferred that tx_end() was not called above. Consequently, we have
|
|
# not released any blocked orders yet. Nevertheless, we can do so now,
|
|
# since the current order is already being confirmed. Go ahead and call
|
|
# tx_start to unblock any orders waiting on the present order.
|
|
#
|
|
# Also, if the incoming confirmation is not the first for the present
|
|
# order, the "last_status" value was already "confirming". In this
|
|
# case, we have already attempted to release blocked orders in a
|
|
# previous call, so there is no need to call tx_start() again.
|
|
db.session.refresh(order)
|
|
if order.status == OrderStatus.confirming.value and \
|
|
last_status == OrderStatus.transmitting.value:
|
|
transmitter.tx_start(order.channel)
|
|
|
|
return {
|
|
'message': f'transmission confirmed for regions {args["regions"]}'
|
|
}
|
|
|
|
|
|
class RxConfirmationResource(Resource):
|
|
|
|
def post(self, tx_seq_num):
|
|
args = request.form
|
|
errors = rx_confirmation_schema.validate(args)
|
|
|
|
if errors:
|
|
return errors, HTTPStatus.BAD_REQUEST
|
|
|
|
order = Order.query.filter_by(tx_seq_num=tx_seq_num).first()
|
|
if not order:
|
|
return get_http_error_resp('SEQUENCE_NUMBER_NOT_FOUND', tx_seq_num)
|
|
|
|
region_in_request = int(args['region'])
|
|
order_helpers.add_confirmation_if_not_present(RxConfirmation, order,
|
|
region_in_request)
|
|
|
|
# Check whether the order is in "sent" or "received" state already. In
|
|
# the positive case, end the current transmission to start a new one.
|
|
if order_helpers.sent_or_received_criteria_met(order):
|
|
transmitter.tx_end(order)
|
|
|
|
return {
|
|
'message': f'reception confirmed for region {region_in_request}'
|
|
}
|