diff --git a/.env-sample b/.env-sample index 84dce51b8..49249da70 100644 --- a/.env-sample +++ b/.env-sample @@ -30,7 +30,7 @@ POSTGRES_HOST='127.0.0.1' POSTGRES_PORT='5432' # Tor proxy for remote calls (e.g. fetching prices or sending Telegram messages) -USE_TOR='True' +USE_TOR=True TOR_PROXY='127.0.0.1:9050' # Auto unlock LND password. Only used in development docker-compose environment. @@ -166,4 +166,4 @@ MINIMUM_TARGET_CONF = 24 SLASHED_BOND_REWARD_SPLIT = 0.5 # Username for HTLCs escrows -ESCROW_USERNAME = 'admin' +ESCROW_USERNAME = 'admin' \ No newline at end of file diff --git a/.github/workflows/django-test.yml b/.github/workflows/django-test.yml deleted file mode 100644 index 1ec936e2d..000000000 --- a/.github/workflows/django-test.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: "Test: Coordinator" - -on: - workflow_dispatch: - workflow_call: - push: - branches: [ "main" ] - paths: ["api", "chat", "control", "robosats"] - pull_request_target: - branches: [ "main" ] - paths: ["api", "chat", "control", "robosats"] - -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - env: - DEVELOPMENT: 1 - strategy: - max-parallel: 4 - matrix: - python-version: ["3.11.6"] # , "3.12"] - - services: - db: - image: postgres:14.2 - env: - POSTGRES_DB: postgres - POSTGRES_USER: postgres - POSTGRES_PASSWORD: example - ports: - - 5432:5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - steps: - - name: 'Checkout' - uses: actions/checkout@v4 - - - name: 'Set up Python ${{ matrix.python-version }}' - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: 'Cache pip dependencies' - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: 'Install Python Dependencies' - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: 'Install LND/CLN gRPC Dependencies' - run: bash ./scripts/generate_grpc.sh - - - name: 'Create .env File' - run: | - mv .env-sample .env - - - name: 'Wait for PostgreSQL to become ready' - run: | - sudo apt-get install -y postgresql-client - until pg_isready -h localhost -p 5432 -U postgres; do sleep 2; done - - - name: 'Run tests with coverage' - run: | - pip install coverage - coverage run manage.py test - coverage report \ No newline at end of file diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..588ff539b --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,77 @@ +name: "Test: Coordinator" + +on: + workflow_dispatch: + workflow_call: + push: + branches: [ "main" ] + paths: ["api", "chat", "control", "robosats"] + pull_request_target: + branches: [ "main" ] + paths: ["api", "chat", "control", "robosats"] + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-tag: ['3.11.6-slim-bookworm', '3.12-slim-bookworm'] + lnd-version: ['v0.17.0-beta'] # , 'v0.17.0-beta.rc1'] + cln-version: ['v23.08.1'] + ln-vendor: ['LND', 'CLN'] + + steps: + - name: 'Checkout' + uses: actions/checkout@v4 + + - name: Patch Dockerfile and .env-sample + run: | + sed -i "1s/FROM python:.*/FROM python:${{ matrix.python-tag }}/" Dockerfile + sed -i '/RUN pip install --no-cache-dir -r requirements.txt/a COPY requirements_dev.txt .\nRUN pip install --no-cache-dir -r requirements_dev.txt' Dockerfile + sed -i "s/^LNVENDOR=.*/LNVENDOR='${{ matrix.ln-vendor }}'/" .env-sample + + - uses: satackey/action-docker-layer-caching@v0.0.11 + continue-on-error: true + with: + key: coordinator-docker-cache-${{ hashFiles('Dockerfile', 'requirements.txt', 'requirements_dev.txt') }} + restore-keys: | + coordinator-docker-cache- + + - name: 'Compose Regtest Orchestration' + uses: isbang/compose-action@v1.5.1 + with: + compose-file: "./docker-tests.yml" + down-flags: "--volumes" + services: | + bitcoind + postgres + redis + coordinator-${{ matrix.ln-vendor }} + robot-LND + coordinator + env: + LND_VERSION: ${{ matrix.lnd-version }} + CLN_VERSION: ${{ matrix.cln-version }} + BITCOIND_VERSION: ${{ matrix.bitcoind-version }} + ROBOSATS_ENVS_FILE: ".env-sample" + + - name: Wait for coordinator (django server) + run: | + while [ "$(docker inspect --format "{{.State.Health.Status}}" coordinator)" != "healthy" ]; do + echo "Waiting for coordinator to be healthy..." + sleep 5 + done + + - name: 'Run tests with coverage' + run: | + docker exec coordinator coverage run manage.py test + docker exec coordinator coverage report + env: + LNVENDOR: ${{ matrix.ln-vendor }} + DEVELOPMENT: True + USE_TOR: False \ No newline at end of file diff --git a/.github/workflows/py-linter.yml b/.github/workflows/py-linter.yml index 7b5f785a1..84ffb110e 100644 --- a/.github/workflows/py-linter.yml +++ b/.github/workflows/py-linter.yml @@ -26,7 +26,7 @@ jobs: with: python-version: '3.11.6' cache: pip - - run: pip install black==22.8.0 flake8==5.0.4 isort==5.10.1 + - run: pip install requirements_dev.txt - name: Run linters uses: wearerequired/lint-action@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ecf6f312..5d2159cdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,8 +38,8 @@ jobs: fi - django-test: - uses: RoboSats/robosats/.github/workflows/django-test.yml@main + integration-tests: + uses: RoboSats/robosats/.github/workflows/integration-tests.yml@main needs: check-versions frontend-build: diff --git a/api/lightning/cln.py b/api/lightning/cln.py index 9d97d4053..ef81f7d82 100755 --- a/api/lightning/cln.py +++ b/api/lightning/cln.py @@ -10,10 +10,7 @@ from decouple import config from django.utils import timezone -from . import hold_pb2 as holdrpc -from . import hold_pb2_grpc as holdstub -from . import node_pb2 as noderpc -from . import node_pb2_grpc as nodestub +from . import hold_pb2, hold_pb2_grpc, node_pb2, node_pb2_grpc from . import primitives_pb2 as primitives__pb2 ####### @@ -51,13 +48,6 @@ class CLNNode: hold_channel = grpc.secure_channel(CLN_GRPC_HOLD_HOST, creds) node_channel = grpc.secure_channel(CLN_GRPC_HOST, creds) - # Create the gRPC stub - hstub = holdstub.HoldStub(hold_channel) - nstub = nodestub.NodeStub(node_channel) - - holdrpc = holdrpc - noderpc = noderpc - payment_failure_context = { -1: "Catchall nonspecific error.", 201: "Already paid with this hash using different amount or destination.", @@ -71,30 +61,39 @@ class CLNNode: @classmethod def get_version(cls): try: - request = noderpc.GetinfoRequest() - print(request) - response = cls.nstub.Getinfo(request) - print(response) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + request = node_pb2.GetinfoRequest() + response = nodestub.Getinfo(request) return response.version except Exception as e: - print(e) - return None + print(f"Cannot get CLN version: {e}") + return "Not installed" + + @classmethod + def get_info(cls): + try: + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + request = node_pb2.GetinfoRequest() + response = nodestub.Getinfo(request) + return response + except Exception as e: + print(f"Cannot get CLN node id: {e}") @classmethod def decode_payreq(cls, invoice): """Decodes a lightning payment request (invoice)""" - request = holdrpc.DecodeBolt11Request(bolt11=invoice) - - response = cls.hstub.DecodeBolt11(request) + request = hold_pb2.DecodeBolt11Request(bolt11=invoice) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.DecodeBolt11(request) return response @classmethod def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): """Returns estimated fee for onchain payouts""" # feerate estimaes work a bit differently in cln see https://lightning.readthedocs.io/lightning-feerates.7.html - request = noderpc.FeeratesRequest(style="PERKB") - - response = cls.nstub.Feerates(request) + request = node_pb2.FeeratesRequest(style="PERKB") + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.Feerates(request) # "opening" -> ~12 block target return { @@ -108,9 +107,9 @@ def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): @classmethod def wallet_balance(cls): """Returns onchain balance""" - request = noderpc.ListfundsRequest() - - response = cls.nstub.ListFunds(request) + request = node_pb2.ListfundsRequest() + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.ListFunds(request) unconfirmed_balance = 0 confirmed_balance = 0 @@ -119,13 +118,13 @@ def wallet_balance(cls): if not utxo.reserved: if ( utxo.status - == noderpc.ListfundsOutputs.ListfundsOutputsStatus.UNCONFIRMED + == node_pb2.ListfundsOutputs.ListfundsOutputsStatus.UNCONFIRMED ): unconfirmed_balance += utxo.amount_msat.msat // 1_000 total_balance += utxo.amount_msat.msat // 1_000 elif ( utxo.status - == noderpc.ListfundsOutputs.ListfundsOutputsStatus.CONFIRMED + == node_pb2.ListfundsOutputs.ListfundsOutputsStatus.CONFIRMED ): confirmed_balance += utxo.amount_msat.msat // 1_000 total_balance += utxo.amount_msat.msat // 1_000 @@ -142,9 +141,9 @@ def wallet_balance(cls): @classmethod def channel_balance(cls): """Returns channels balance""" - request = noderpc.ListpeerchannelsRequest() - - response = cls.nstub.ListPeerChannels(request) + request = node_pb2.ListpeerchannelsRequest() + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.ListPeerChannels(request) local_balance_sat = 0 remote_balance_sat = 0 @@ -153,7 +152,7 @@ def channel_balance(cls): for channel in response.channels: if ( channel.state - == noderpc.ListpeerchannelsChannels.ListpeerchannelsChannelsState.CHANNELD_NORMAL + == node_pb2.ListpeerchannelsChannels.ListpeerchannelsChannelsState.CHANNELD_NORMAL ): local_balance_sat += channel.to_us_msat.msat // 1_000 remote_balance_sat += ( @@ -162,12 +161,12 @@ def channel_balance(cls): for htlc in channel.htlcs: if ( htlc.direction - == noderpc.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.IN + == node_pb2.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.IN ): unsettled_local_balance += htlc.amount_msat.msat // 1_000 elif ( htlc.direction - == noderpc.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.OUT + == node_pb2.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.OUT ): unsettled_remote_balance += htlc.amount_msat.msat // 1_000 @@ -185,7 +184,7 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): if DISABLE_ONCHAIN or onchainpayment.sent_satoshis > MAX_SWAP_AMOUNT: return False - request = noderpc.WithdrawRequest( + request = node_pb2.WithdrawRequest( destination=onchainpayment.address, satoshi=primitives__pb2.AmountOrAll( amount=primitives__pb2.Amount(msat=onchainpayment.sent_satoshis * 1_000) @@ -206,7 +205,8 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): # Changing the state to "MEMPO" should be atomic with SendCoins. onchainpayment.status = on_mempool_code onchainpayment.save(update_fields=["status"]) - response = cls.nstub.Withdraw(request) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.Withdraw(request) if response.txid: onchainpayment.txid = response.txid.hex() @@ -221,22 +221,24 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): @classmethod def cancel_return_hold_invoice(cls, payment_hash): """Cancels or returns a hold invoice""" - request = holdrpc.HoldInvoiceCancelRequest( + request = hold_pb2.HoldInvoiceCancelRequest( payment_hash=bytes.fromhex(payment_hash) ) - response = cls.hstub.HoldInvoiceCancel(request) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.HoldInvoiceCancel(request) - return response.state == holdrpc.HoldInvoiceCancelResponse.Holdstate.CANCELED + return response.state == hold_pb2.HoldInvoiceCancelResponse.Holdstate.CANCELED @classmethod def settle_hold_invoice(cls, preimage): """settles a hold invoice""" - request = holdrpc.HoldInvoiceSettleRequest( + request = hold_pb2.HoldInvoiceSettleRequest( payment_hash=hashlib.sha256(bytes.fromhex(preimage)).digest() ) - response = cls.hstub.HoldInvoiceSettle(request) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.HoldInvoiceSettle(request) - return response.state == holdrpc.HoldInvoiceSettleResponse.Holdstate.SETTLED + return response.state == hold_pb2.HoldInvoiceSettleResponse.Holdstate.SETTLED @classmethod def gen_hold_invoice( @@ -259,7 +261,7 @@ def gen_hold_invoice( # The preimage is a random hash of 256 bits entropy preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest() - request = holdrpc.HoldInvoiceRequest( + request = hold_pb2.HoldInvoiceRequest( description=description, amount_msat=primitives__pb2.Amount(msat=num_satoshis * 1_000), label=f"Order:{order_id}-{lnpayment_concept}-{time}", @@ -267,7 +269,8 @@ def gen_hold_invoice( cltv=cltv_expiry_blocks, preimage=preimage, # preimage is actually optional in cln, as cln would generate one by default ) - response = cls.hstub.HoldInvoice(request) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.HoldInvoice(request) hold_payment["invoice"] = response.bolt11 payreq_decoded = cls.decode_payreq(hold_payment["invoice"]) @@ -288,21 +291,22 @@ def validate_hold_invoice_locked(cls, lnpayment): """Checks if hold invoice is locked""" from api.models import LNPayment - request = holdrpc.HoldInvoiceLookupRequest( + request = hold_pb2.HoldInvoiceLookupRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) - response = cls.hstub.HoldInvoiceLookup(request) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.HoldInvoiceLookup(request) # Will fail if 'unable to locate invoice'. Happens if invoice expiry # time has passed (but these are 15% padded at the moment). Should catch it # and report back that the invoice has expired (better robustness) - if response.state == holdrpc.HoldInvoiceLookupResponse.Holdstate.OPEN: + if response.state == hold_pb2.HoldInvoiceLookupResponse.Holdstate.OPEN: pass - if response.state == holdrpc.HoldInvoiceLookupResponse.Holdstate.SETTLED: + if response.state == hold_pb2.HoldInvoiceLookupResponse.Holdstate.SETTLED: pass - if response.state == holdrpc.HoldInvoiceLookupResponse.Holdstate.CANCELED: + if response.state == hold_pb2.HoldInvoiceLookupResponse.Holdstate.CANCELED: pass - if response.state == holdrpc.HoldInvoiceLookupResponse.Holdstate.ACCEPTED: + if response.state == hold_pb2.HoldInvoiceLookupResponse.Holdstate.ACCEPTED: lnpayment.expiry_height = response.htlc_expiry lnpayment.status = LNPayment.Status.LOCKED lnpayment.save(update_fields=["expiry_height", "status"]) @@ -328,10 +332,11 @@ def lookup_invoice_status(cls, lnpayment): try: # this is similar to LNNnode.validate_hold_invoice_locked - request = holdrpc.HoldInvoiceLookupRequest( + request = hold_pb2.HoldInvoiceLookupRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) - response = cls.hstub.HoldInvoiceLookup(request) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.HoldInvoiceLookup(request) status = cln_response_state_to_lnpayment_status[response.state] @@ -348,22 +353,23 @@ def lookup_invoice_status(cls, lnpayment): # (cln-grpc-hodl has separate state for hodl-invoices, which it forgets after an invoice expired more than an hour ago) if "empty result for listdatastore_state" in str(e): print(str(e)) - request2 = noderpc.ListinvoicesRequest( + request2 = node_pb2.ListinvoicesRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) try: - response2 = cls.nstub.ListInvoices(request2).invoices + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response2 = nodestub.ListInvoices(request2).invoices except Exception as e: print(str(e)) if ( response2[0].status - == noderpc.ListinvoicesInvoices.ListinvoicesInvoicesStatus.PAID + == node_pb2.ListinvoicesInvoices.ListinvoicesInvoicesStatus.PAID ): status = LNPayment.Status.SETLED elif ( response2[0].status - == noderpc.ListinvoicesInvoices.ListinvoicesInvoicesStatus.EXPIRED + == node_pb2.ListinvoicesInvoices.ListinvoicesInvoicesStatus.EXPIRED ): status = LNPayment.Status.CANCEL else: @@ -482,16 +488,17 @@ def pay_invoice(cls, lnpayment): ) ) # 200 ppm or 10 sats timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) - request = noderpc.PayRequest( + request = node_pb2.PayRequest( bolt11=lnpayment.invoice, maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000), retry_for=timeout_seconds, ) try: - response = cls.nstub.Pay(request) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.Pay(request) - if response.status == noderpc.PayResponse.PayStatus.COMPLETE: + if response.status == node_pb2.PayResponse.PayStatus.COMPLETE: lnpayment.status = LNPayment.Status.SUCCED lnpayment.fee = ( float(response.amount_sent_msat.msat - response.amount_msat.msat) @@ -500,13 +507,13 @@ def pay_invoice(cls, lnpayment): lnpayment.preimage = response.payment_preimage.hex() lnpayment.save(update_fields=["fee", "status", "preimage"]) return True, None - elif response.status == noderpc.PayResponse.PayStatus.PENDING: + elif response.status == node_pb2.PayResponse.PayStatus.PENDING: failure_reason = "Payment isn't failed (yet)" lnpayment.failure_reason = LNPayment.FailureReason.NOTYETF lnpayment.status = LNPayment.Status.FLIGHT lnpayment.save(update_fields=["failure_reason", "status"]) return False, failure_reason - else: # response.status == noderpc.PayResponse.PayStatus.FAILED + else: # response.status == node_pb2.PayResponse.PayStatus.FAILED failure_reason = "All possible routes were tried and failed permanently. Or were no routes to the destination at all." lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE lnpayment.status = LNPayment.Status.FAILRO @@ -530,7 +537,7 @@ def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): # retry_for is not quite the same as a timeout. Pay can still take SIGNIFICANTLY longer to return if htlcs are stuck! # allow_self_payment=True, No such thing in pay command and self_payments do not work with pay! - request = noderpc.PayRequest( + request = node_pb2.PayRequest( bolt11=lnpayment.invoice, maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000), retry_for=timeout_seconds, @@ -542,10 +549,13 @@ def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): return def watchpayment(): - request_listpays = noderpc.ListpaysRequest(payment_hash=bytes.fromhex(hash)) + request_listpays = node_pb2.ListpaysRequest( + payment_hash=bytes.fromhex(hash) + ) while True: try: - response_listpays = cls.nstub.ListPays(request_listpays) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response_listpays = nodestub.ListPays(request_listpays) except Exception as e: print(str(e)) time.sleep(2) @@ -554,7 +564,7 @@ def watchpayment(): if ( len(response_listpays.pays) == 0 or response_listpays.pays[0].status - != noderpc.ListpaysPays.ListpaysPaysStatus.PENDING + != node_pb2.ListpaysPays.ListpaysPaysStatus.PENDING ): return response_listpays else: @@ -567,17 +577,17 @@ def handle_response(): lnpayment.save(update_fields=["in_flight", "status"]) order.update_status(Order.Status.PAY) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.Pay(request) - response = cls.nstub.Pay(request) - - if response.status == noderpc.PayResponse.PayStatus.PENDING: + if response.status == node_pb2.PayResponse.PayStatus.PENDING: print(f"Order: {order.id} IN_FLIGHT. Hash {hash}") watchpayment() handle_response() - if response.status == noderpc.PayResponse.PayStatus.FAILED: + if response.status == node_pb2.PayResponse.PayStatus.FAILED: lnpayment.status = LNPayment.Status.FAILRO lnpayment.last_routing_time = timezone.now() lnpayment.routing_attempts += 1 @@ -614,7 +624,7 @@ def handle_response(): "context": f"payment failure reason: {cls.payment_failure_context[-1]}", } - if response.status == noderpc.PayResponse.PayStatus.COMPLETE: + if response.status == node_pb2.PayResponse.PayStatus.COMPLETE: print(f"Order: {order.id} SUCCEEDED. Hash: {hash}") lnpayment.status = LNPayment.Status.SUCCED lnpayment.fee = ( @@ -702,7 +712,7 @@ def handle_response(): if ( len(last_payresponse.pays) > 0 and last_payresponse.pays[0].status - == noderpc.ListpaysPays.ListpaysPaysStatus.COMPLETE + == node_pb2.ListpaysPays.ListpaysPaysStatus.COMPLETE ): handle_response() else: @@ -763,10 +773,12 @@ def send_keysend( ) ) if sign: - self_pubkey = cls.nstub.GetInfo(noderpc.GetinfoRequest()).id + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + self_pubkey = nodestub.Getinfo(node_pb2.GetinfoRequest()).id timestamp = struct.pack(">i", int(time.time())) - signature = cls.nstub.SignMessage( - noderpc.SignmessageRequest( + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + signature = nodestub.SignMessage( + node_pb2.SignmessageRequest( message=( bytes.fromhex(self_pubkey) + bytes.fromhex(target_pubkey) @@ -789,23 +801,25 @@ def send_keysend( # no maxfee for Keysend maxfeepercent = (routing_budget_sats / num_satoshis) * 100 - request = noderpc.KeysendRequest( + request = node_pb2.KeysendRequest( destination=bytes.fromhex(target_pubkey), extratlvs=primitives__pb2.TlvStream(entries=custom_records), maxfeepercent=maxfeepercent, retry_for=timeout, amount_msat=primitives__pb2.Amount(msat=num_satoshis * 1000), ) - response = cls.nstub.KeySend(request) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + response = nodestub.KeySend(request) keysend_payment["preimage"] = response.payment_preimage.hex() keysend_payment["payment_hash"] = response.payment_hash.hex() - waitreq = noderpc.WaitsendpayRequest( + waitreq = node_pb2.WaitsendpayRequest( payment_hash=response.payment_hash, timeout=timeout ) try: - waitresp = cls.nstub.WaitSendPay(waitreq) + nodestub = node_pb2_grpc.NodeStub(cls.node_channel) + waitresp = nodestub.WaitSendPay(waitreq) keysend_payment["fee"] = ( float(waitresp.amount_sent_msat.msat - waitresp.amount_msat.msat) / 1000 @@ -834,15 +848,16 @@ def send_keysend( @classmethod def double_check_htlc_is_settled(cls, payment_hash): """Just as it sounds. Better safe than sorry!""" - request = holdrpc.HoldInvoiceLookupRequest( + request = hold_pb2.HoldInvoiceLookupRequest( payment_hash=bytes.fromhex(payment_hash) ) try: - response = cls.hstub.HoldInvoiceLookup(request) + holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) + response = holdstub.HoldInvoiceLookup(request) except Exception as e: if "Timed out" in str(e): return False else: raise e - return response.state == holdrpc.HoldInvoiceLookupResponse.Holdstate.SETTLED + return response.state == hold_pb2.HoldInvoiceLookupResponse.Holdstate.SETTLED diff --git a/api/lightning/lnd.py b/api/lightning/lnd.py index c56ff7b50..bf831fa93 100644 --- a/api/lightning/lnd.py +++ b/api/lightning/lnd.py @@ -11,16 +11,18 @@ from decouple import config from django.utils import timezone -from . import invoices_pb2 as invoicesrpc -from . import invoices_pb2_grpc as invoicesstub -from . import lightning_pb2 as lnrpc -from . import lightning_pb2_grpc as lightningstub -from . import router_pb2 as routerrpc -from . import router_pb2_grpc as routerstub -from . import signer_pb2 as signerrpc -from . import signer_pb2_grpc as signerstub -from . import verrpc_pb2 as verrpc -from . import verrpc_pb2_grpc as verstub +from . import ( + invoices_pb2, + invoices_pb2_grpc, + lightning_pb2, + lightning_pb2_grpc, + router_pb2, + router_pb2_grpc, + signer_pb2, + signer_pb2_grpc, + verrpc_pb2, + verrpc_pb2_grpc, +) ####### # Works with LND (c-lightning in the future for multi-vendor resilience) @@ -35,7 +37,7 @@ # Read macaroon from file or .env variable string encoded as base64 try: - with open(os.path.join(config("LND_DIR"), config("MACAROON_path")), "rb") as f: + with open(os.path.join(config("LND_DIR"), config("MACAROON_PATH")), "rb") as f: MACAROON = f.read() except Exception: MACAROON = b64decode(config("LND_MACAROON_BASE64")) @@ -45,6 +47,17 @@ MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500_000) +# Logger function used to build tests/mocks/lnd.py +def log(name, request, response): + if not config("LOG_LND", cast=bool, default=False): + return + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_message = f"######################################\nEvent: {name}\nTime: {current_time}\nRequest:\n{request}\nResponse:\n{response}\nType: {type(response)}\n" + + with open("lnd_log.txt", "a") as file: + file.write(log_message) + + class LNDNode: os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" @@ -56,12 +69,6 @@ def metadata_callback(context, callback): combined_creds = grpc.composite_channel_credentials(ssl_creds, auth_creds) channel = grpc.secure_channel(LND_GRPC_HOST, combined_creds) - lightningstub = lightningstub.LightningStub(channel) - invoicesstub = invoicesstub.InvoicesStub(channel) - routerstub = routerstub.RouterStub(channel) - signerstub = signerstub.SignerStub(channel) - verstub = verstub.VersionerStub(channel) - payment_failure_context = { 0: "Payment isn't failed (yet)", 1: "There are more routes to try, but the payment timeout was exceeded.", @@ -71,43 +78,50 @@ def metadata_callback(context, callback): 5: "Insufficient local balance.", } - is_testnet = lightningstub.GetInfo(lnrpc.GetInfoRequest()).testnet - @classmethod def get_version(cls): try: - request = verrpc.VersionRequest() - response = cls.verstub.GetVersion(request) + request = verrpc_pb2.VersionRequest() + verstub = verrpc_pb2_grpc.VersionerStub(cls.channel) + response = verstub.GetVersion(request) + log("verstub.GetVersion", request, response) return "v" + response.version except Exception as e: - print(e) - return None + print(f"Cannot get CLN version: {e}") + return "Not installed" @classmethod def decode_payreq(cls, invoice): """Decodes a lightning payment request (invoice)""" - request = lnrpc.PayReqString(pay_req=invoice) - response = cls.lightningstub.DecodePayReq(request) + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + request = lightning_pb2.PayReqString(pay_req=invoice) + response = lightningstub.DecodePayReq(request) + log("lightning_pb2_grpc.DecodePayReq", request, response) return response @classmethod def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): """Returns estimated fee for onchain payouts""" + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + request = lightning_pb2.GetInfoRequest() + response = lightningstub.GetInfo(request) + log("lightning_pb2_grpc.GetInfo", request, response) - if cls.is_testnet: + if response.testnet: dummy_address = "tb1qehyqhruxwl2p5pt52k6nxj4v8wwc3f3pg7377x" else: dummy_address = "bc1qgxwaqe4m9mypd7ltww53yv3lyxhcfnhzzvy5j3" - # We assume segwit. Use hardcoded address as shortcut so there is no need of user inputs yet. - request = lnrpc.EstimateFeeRequest( + request = lightning_pb2.EstimateFeeRequest( AddrToAmount={dummy_address: amount_sats}, target_conf=target_conf, min_confs=min_confs, spend_unconfirmed=False, ) - response = cls.lightningstub.EstimateFee(request) + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + response = lightningstub.EstimateFee(request) + log("lightning_pb2_grpc.EstimateFee", request, response) return { "mining_fee_sats": response.fee_sat, @@ -120,8 +134,10 @@ def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): @classmethod def wallet_balance(cls): """Returns onchain balance""" - request = lnrpc.WalletBalanceRequest() - response = cls.lightningstub.WalletBalance(request) + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + request = lightning_pb2.WalletBalanceRequest() + response = lightningstub.WalletBalance(request) + log("lightning_pb2_grpc.WalletBalance", request, response) return { "total_balance": response.total_balance, @@ -135,8 +151,10 @@ def wallet_balance(cls): @classmethod def channel_balance(cls): """Returns channels balance""" - request = lnrpc.ChannelBalanceRequest() - response = cls.lightningstub.ChannelBalance(request) + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + request = lightning_pb2.ChannelBalanceRequest() + response = lightningstub.ChannelBalance(request) + log("lightning_pb2_grpc.ChannelBalance", request, response) return { "local_balance": response.local_balance.sat, @@ -152,7 +170,7 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): if DISABLE_ONCHAIN or onchainpayment.sent_satoshis > MAX_SWAP_AMOUNT: return False - request = lnrpc.SendCoinsRequest( + request = lightning_pb2.SendCoinsRequest( addr=onchainpayment.address, amount=int(onchainpayment.sent_satoshis), sat_per_vbyte=int(onchainpayment.mining_fee_rate), @@ -170,7 +188,9 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): # Changing the state to "MEMPO" should be atomic with SendCoins. onchainpayment.status = on_mempool_code onchainpayment.save(update_fields=["status"]) - response = cls.lightningstub.SendCoins(request) + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + response = lightningstub.SendCoins(request) + log("lightning_pb2_grpc.SendCoins", request, response) if response.txid: onchainpayment.txid = response.txid @@ -192,16 +212,22 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): @classmethod def cancel_return_hold_invoice(cls, payment_hash): """Cancels or returns a hold invoice""" - request = invoicesrpc.CancelInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) - response = cls.invoicesstub.CancelInvoice(request) + request = invoices_pb2.CancelInvoiceMsg( + payment_hash=bytes.fromhex(payment_hash) + ) + invoicesstub = invoices_pb2_grpc.InvoicesStub(cls.channel) + response = invoicesstub.CancelInvoice(request) + log("invoices_pb2_grpc.CancelInvoice", request, response) # Fix this: tricky because canceling sucessfully an invoice has no response. TODO return str(response) == "" # True if no response, false otherwise. @classmethod def settle_hold_invoice(cls, preimage): """settles a hold invoice""" - request = invoicesrpc.SettleInvoiceMsg(preimage=bytes.fromhex(preimage)) - response = cls.invoicesstub.SettleInvoice(request) + request = invoices_pb2.SettleInvoiceMsg(preimage=bytes.fromhex(preimage)) + invoicesstub = invoices_pb2_grpc.InvoicesStub(cls.channel) + response = invoicesstub.SettleInvoice(request) + log("invoices_pb2_grpc.SettleInvoice", request, response) # Fix this: tricky because settling sucessfully an invoice has None response. TODO return str(response) == "" # True if no response, false otherwise. @@ -217,7 +243,6 @@ def gen_hold_invoice( time, ): """Generates hold invoice""" - hold_payment = {} # The preimage is a random hash of 256 bits entropy preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest() @@ -225,7 +250,7 @@ def gen_hold_invoice( # Its hash is used to generate the hold invoice r_hash = hashlib.sha256(preimage).digest() - request = invoicesrpc.AddHoldInvoiceRequest( + request = invoices_pb2.AddHoldInvoiceRequest( memo=description, value=num_satoshis, hash=r_hash, @@ -234,7 +259,9 @@ def gen_hold_invoice( ), # actual expiry is padded by 50%, if tight, wrong client system clock will say invoice is expired. cltv_expiry=cltv_expiry_blocks, ) - response = cls.invoicesstub.AddHoldInvoice(request) + invoicesstub = invoices_pb2_grpc.InvoicesStub(cls.channel) + response = invoicesstub.AddHoldInvoice(request) + log("invoices_pb2_grpc.AddHoldInvoice", request, response) hold_payment["invoice"] = response.payment_request payreq_decoded = cls.decode_payreq(hold_payment["invoice"]) @@ -255,21 +282,25 @@ def validate_hold_invoice_locked(cls, lnpayment): """Checks if hold invoice is locked""" from api.models import LNPayment - request = invoicesrpc.LookupInvoiceMsg( + request = invoices_pb2.LookupInvoiceMsg( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) - response = cls.invoicesstub.LookupInvoiceV2(request) + invoicesstub = invoices_pb2_grpc.InvoicesStub(cls.channel) + response = invoicesstub.LookupInvoiceV2(request) + log("invoices_pb2_grpc.LookupInvoiceV2", request, response) # Will fail if 'unable to locate invoice'. Happens if invoice expiry # time has passed (but these are 15% padded at the moment). Should catch it # and report back that the invoice has expired (better robustness) - if response.state == lnrpc.Invoice.InvoiceState.OPEN: # OPEN + if response.state == lightning_pb2.Invoice.InvoiceState.OPEN: # OPEN pass - if response.state == lnrpc.Invoice.InvoiceState.SETTLED: # SETTLED + if response.state == lightning_pb2.Invoice.InvoiceState.SETTLED: # SETTLED pass - if response.state == lnrpc.Invoice.InvoiceState.CANCELED: # CANCELED + if response.state == lightning_pb2.Invoice.InvoiceState.CANCELED: # CANCELED pass - if response.state == lnrpc.Invoice.InvoiceState.ACCEPTED: # ACCEPTED (LOCKED) + if ( + response.state == lightning_pb2.Invoice.InvoiceState.ACCEPTED + ): # ACCEPTED (LOCKED) lnpayment.expiry_height = response.htlcs[0].expiry_height lnpayment.status = LNPayment.Status.LOCKED lnpayment.save(update_fields=["expiry_height", "status"]) @@ -295,10 +326,12 @@ def lookup_invoice_status(cls, lnpayment): try: # this is similar to LNNnode.validate_hold_invoice_locked - request = invoicesrpc.LookupInvoiceMsg( + request = invoices_pb2.LookupInvoiceMsg( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) - response = cls.invoicesstub.LookupInvoiceV2(request) + invoicesstub = invoices_pb2_grpc.InvoicesStub(cls.channel) + response = invoicesstub.LookupInvoiceV2(request) + log("invoices_pb2_grpc.LookupInvoiceV2", request, response) status = lnd_response_state_to_lnpayment_status[response.state] @@ -329,8 +362,9 @@ def lookup_invoice_status(cls, lnpayment): @classmethod def resetmc(cls): - request = routerrpc.ResetMissionControlRequest() - _ = cls.routerstub.ResetMissionControl(request) + routerstub = router_pb2_grpc.RouterStub(cls.channel) + request = router_pb2.ResetMissionControlRequest() + _ = routerstub.ResetMissionControl(request) return True @classmethod @@ -437,26 +471,28 @@ def pay_invoice(cls, lnpayment): ) ) # 200 ppm or 10 sats timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) - request = routerrpc.SendPaymentRequest( + request = router_pb2.SendPaymentRequest( payment_request=lnpayment.invoice, fee_limit_sat=fee_limit_sat, timeout_seconds=timeout_seconds, ) - for response in cls.routerstub.SendPaymentV2(request): + routerstub = router_pb2_grpc.RouterStub(cls.channel) + for response in routerstub.SendPaymentV2(request): + log("router_pb2_grpc.SendPaymentV2", request, response) if ( - response.status == lnrpc.Payment.PaymentStatus.UNKNOWN + response.status == lightning_pb2.Payment.PaymentStatus.UNKNOWN ): # Status 0 'UNKNOWN' # Not sure when this status happens pass if ( - response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT + response.status == lightning_pb2.Payment.PaymentStatus.IN_FLIGHT ): # Status 1 'IN_FLIGHT' pass if ( - response.status == lnrpc.Payment.PaymentStatus.FAILED + response.status == lightning_pb2.Payment.PaymentStatus.FAILED ): # Status 3 'FAILED' """0 Payment isn't failed (yet). 1 There are more routes to try, but the payment timeout was exceeded. @@ -472,7 +508,7 @@ def pay_invoice(cls, lnpayment): return False, failure_reason if ( - response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED + response.status == lightning_pb2.Payment.PaymentStatus.SUCCEEDED ): # STATUS 'SUCCEEDED' lnpayment.status = LNPayment.Status.SUCCED lnpayment.fee = float(response.fee_msat) / 1000 @@ -492,7 +528,7 @@ def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): hash = lnpayment.payment_hash - request = routerrpc.SendPaymentRequest( + request = router_pb2.SendPaymentRequest( payment_request=lnpayment.invoice, fee_limit_sat=fee_limit_sat, timeout_seconds=timeout_seconds, @@ -512,7 +548,7 @@ def handle_response(response, was_in_transit=False): order.save(update_fields=["status"]) if ( - response.status == lnrpc.Payment.PaymentStatus.UNKNOWN + response.status == lightning_pb2.Payment.PaymentStatus.UNKNOWN ): # Status 0 'UNKNOWN' # Not sure when this status happens print(f"Order: {order.id} UNKNOWN. Hash {hash}") @@ -520,7 +556,7 @@ def handle_response(response, was_in_transit=False): lnpayment.save(update_fields=["in_flight"]) if ( - response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT + response.status == lightning_pb2.Payment.PaymentStatus.IN_FLIGHT ): # Status 1 'IN_FLIGHT' print(f"Order: {order.id} IN_FLIGHT. Hash {hash}") @@ -533,7 +569,7 @@ def handle_response(response, was_in_transit=False): lnpayment.save(update_fields=["last_routing_time"]) if ( - response.status == lnrpc.Payment.PaymentStatus.FAILED + response.status == lightning_pb2.Payment.PaymentStatus.FAILED ): # Status 3 'FAILED' lnpayment.status = LNPayment.Status.FAILRO lnpayment.last_routing_time = timezone.now() @@ -576,7 +612,7 @@ def handle_response(response, was_in_transit=False): } if ( - response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED + response.status == lightning_pb2.Payment.PaymentStatus.SUCCEEDED ): # Status 2 'SUCCEEDED' print(f"Order: {order.id} SUCCEEDED. Hash: {hash}") lnpayment.status = LNPayment.Status.SUCCED @@ -598,7 +634,9 @@ def handle_response(response, was_in_transit=False): return results try: - for response in cls.routerstub.SendPaymentV2(request): + routerstub = router_pb2_grpc.RouterStub(cls.channel) + for response in routerstub.SendPaymentV2(request): + log("router_pb2_grpc.SendPaymentV2", request, response) handle_response(response) except Exception as e: @@ -606,11 +644,13 @@ def handle_response(response, was_in_transit=False): print(f"Order: {order.id}. INVOICE EXPIRED. Hash: {hash}") # An expired invoice can already be in-flight. Check. try: - request = routerrpc.TrackPaymentRequest( + request = router_pb2.TrackPaymentRequest( payment_hash=bytes.fromhex(hash) ) - for response in cls.routerstub.TrackPaymentV2(request): + routerstub = router_pb2_grpc.RouterStub(cls.channel) + for response in routerstub.TrackPaymentV2(request): + log("router_pb2_grpc.TrackPaymentV2", request, response) handle_response(response, was_in_transit=True) except Exception as e: @@ -645,21 +685,25 @@ def handle_response(response, was_in_transit=False): elif "payment is in transition" in str(e): print(f"Order: {order.id} ALREADY IN TRANSITION. Hash: {hash}.") - request = routerrpc.TrackPaymentRequest( + request = router_pb2.TrackPaymentRequest( payment_hash=bytes.fromhex(hash) ) - for response in cls.routerstub.TrackPaymentV2(request): + routerstub = router_pb2_grpc.RouterStub(cls.channel) + for response in routerstub.TrackPaymentV2(request): + log("router_pb2_grpc.TrackPaymentV2", request, response) handle_response(response, was_in_transit=True) elif "invoice is already paid" in str(e): print(f"Order: {order.id} ALREADY PAID. Hash: {hash}.") - request = routerrpc.TrackPaymentRequest( + request = router_pb2.TrackPaymentRequest( payment_hash=bytes.fromhex(hash) ) - for response in cls.routerstub.TrackPaymentV2(request): + routerstub = router_pb2_grpc.RouterStub(cls.channel) + for response in routerstub.TrackPaymentV2(request): + log("router_pb2_grpc.TrackPaymentV2", request, response) handle_response(response) else: @@ -694,26 +738,28 @@ def send_keysend( (34349334, bytes.fromhex(msg.encode("utf-8").hex())) ) if sign: - self_pubkey = cls.lightningstub.GetInfo( - lnrpc.GetInfoRequest() + lightningstub = lightning_pb2_grpc.LightningStub(cls.channel) + self_pubkey = lightningstub.GetInfo( + lightning_pb2.GetInfoRequest() ).identity_pubkey timestamp = struct.pack(">i", int(time.time())) - signature = cls.signerstub.SignMessage( - signerrpc.SignMessageReq( + signerstub = signer_pb2_grpc.SignerStub(cls.channel) + signature = signerstub.SignMessage( + signer_pb2.SignMessageReq( msg=( bytes.fromhex(self_pubkey) + bytes.fromhex(target_pubkey) + timestamp + bytes.fromhex(msg.encode("utf-8").hex()) ), - key_loc=signerrpc.KeyLocator(key_family=6, key_index=0), + key_loc=signer_pb2.KeyLocator(key_family=6, key_index=0), ) ).signature custom_records.append((34349337, signature)) custom_records.append((34349339, bytes.fromhex(self_pubkey))) custom_records.append((34349343, timestamp)) - request = routerrpc.SendPaymentRequest( + request = router_pb2.SendPaymentRequest( dest=bytes.fromhex(target_pubkey), dest_custom_records=custom_records, fee_limit_sat=routing_budget_sats, @@ -722,16 +768,18 @@ def send_keysend( payment_hash=bytes.fromhex(hashed_secret), allow_self_payment=ALLOW_SELF_KEYSEND, ) - for response in cls.routerstub.SendPaymentV2(request): - if response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT: + routerstub = router_pb2_grpc.RouterStub(cls.channel) + for response in routerstub.SendPaymentV2(request): + log("router_pb2_grpc.SendPaymentV2", request, response) + if response.status == lightning_pb2.Payment.PaymentStatus.IN_FLIGHT: keysend_payment["status"] = LNPayment.Status.FLIGHT - if response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED: + if response.status == lightning_pb2.Payment.PaymentStatus.SUCCEEDED: keysend_payment["fee"] = float(response.fee_msat) / 1000 keysend_payment["status"] = LNPayment.Status.SUCCED - if response.status == lnrpc.Payment.PaymentStatus.FAILED: + if response.status == lightning_pb2.Payment.PaymentStatus.FAILED: keysend_payment["status"] = LNPayment.Status.FAILRO keysend_payment["failure_reason"] = response.failure_reason - if response.status == lnrpc.Payment.PaymentStatus.UNKNOWN: + if response.status == lightning_pb2.Payment.PaymentStatus.UNKNOWN: print("Unknown Error") except Exception as e: if "self-payments not allowed" in str(e): @@ -744,9 +792,13 @@ def send_keysend( @classmethod def double_check_htlc_is_settled(cls, payment_hash): """Just as it sounds. Better safe than sorry!""" - request = invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) - response = cls.invoicesstub.LookupInvoiceV2(request) + request = invoices_pb2.LookupInvoiceMsg( + payment_hash=bytes.fromhex(payment_hash) + ) + invoicesstub = invoices_pb2_grpc.InvoicesStub(cls.channel) + response = invoicesstub.LookupInvoiceV2(request) + log("invoices_pb2_grpc.LookupInvoiceV2", request, response) return ( - response.state == lnrpc.Invoice.InvoiceState.SETTLED + response.state == lightning_pb2.Invoice.InvoiceState.SETTLED ) # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when CANCELED/returned diff --git a/api/models/order.py b/api/models/order.py index 147621343..501612811 100644 --- a/api/models/order.py +++ b/api/models/order.py @@ -1,3 +1,4 @@ +# We use custom seeded UUID generation during testing import uuid from decouple import config @@ -9,6 +10,19 @@ from django.dispatch import receiver from django.utils import timezone +if config("TESTING", cast=bool, default=False): + import random + import string + + random.seed(1) + chars = string.ascii_lowercase + string.digits + + def custom_uuid(): + return uuid.uuid5(uuid.NAMESPACE_DNS, "".join(random.choices(chars, k=20))) + +else: + custom_uuid = uuid.uuid4 + class Order(models.Model): class Types(models.IntegerChoices): @@ -44,7 +58,7 @@ class ExpiryReasons(models.IntegerChoices): NESINV = 4, "Neither escrow locked or invoice submitted" # order info - reference = models.UUIDField(default=uuid.uuid4, editable=False) + reference = models.UUIDField(default=custom_uuid, editable=False) status = models.PositiveSmallIntegerField( choices=Status.choices, null=False, default=Status.WFB ) diff --git a/api/oas_schemas.py b/api/oas_schemas.py index ed59bcd50..569e9658d 100644 --- a/api/oas_schemas.py +++ b/api/oas_schemas.py @@ -5,6 +5,7 @@ from drf_spectacular.utils import OpenApiExample, OpenApiParameter from api.serializers import ( + InfoSerializer, ListOrderSerializer, OrderDetailSerializer, StealthSerializer, @@ -322,17 +323,7 @@ class OrderViewSchema: ), ], "responses": { - 200: { - "type": "object", - "additionalProperties": { - "oneOf": [ - {"type": "str"}, - {"type": "number"}, - {"type": "object"}, - {"type": "boolean"}, - ], - }, - }, + 200: OrderDetailSerializer, 400: { "type": "object", "properties": { @@ -474,6 +465,16 @@ class RobotViewSchema: "type": "integer", "description": "Last order id if present", }, + "earned_rewards": { + "type": "integer", + "description": "Satoshis available to be claimed", + }, + "last_login": { + "type": "string", + "format": "date-time", + "nullable": True, + "description": "Last time the coordinator saw this robot", + }, }, }, }, @@ -517,6 +518,9 @@ class InfoViewSchema: - on-chain swap fees """ ), + "responses": { + 200: InfoSerializer, + }, } diff --git a/api/serializers.py b/api/serializers.py index d2b0db2a5..43419c239 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -6,13 +6,19 @@ RETRY_TIME = int(config("RETRY_TIME")) +class VersionSerializer(serializers.Serializer): + major = serializers.IntegerField() + minor = serializers.IntegerField() + patch = serializers.IntegerField() + + class InfoSerializer(serializers.Serializer): num_public_buy_orders = serializers.IntegerField() num_public_sell_orders = serializers.IntegerField() book_liquidity = serializers.IntegerField( help_text="Total amount of BTC in the order book" ) - active_robots_today = serializers.CharField() + active_robots_today = serializers.IntegerField() last_day_nonkyc_btc_premium = serializers.FloatField( help_text="Average premium (weighted by volume) of the orders in the last 24h" ) @@ -23,6 +29,7 @@ class InfoSerializer(serializers.Serializer): help_text="Total volume in BTC since exchange's inception" ) lnd_version = serializers.CharField() + cln_version = serializers.CharField() robosats_running_commit_hash = serializers.CharField() alternative_site = serializers.CharField() alternative_name = serializers.CharField() @@ -35,6 +42,17 @@ class InfoSerializer(serializers.Serializer): current_swap_fee_rate = serializers.FloatField( help_text="Swap fees to perform on-chain transaction (percent)" ) + version = VersionSerializer() + notice_severity = serializers.ChoiceField( + choices=[ + ("none", "none"), + ("warning", "warning"), + ("success", "success"), + ("error", "error"), + ("info", "info"), + ] + ) + notice_message = serializers.CharField() class ListOrderSerializer(serializers.ModelSerializer): @@ -60,7 +78,7 @@ class Meta: "escrow_duration", "bond_size", "latitude", - "longitude" + "longitude", ) @@ -152,18 +170,25 @@ class OrderDetailSerializer(serializers.ModelSerializer): "- **'Inactive'** (seen more than 10 min ago)\n\n" "Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty", ) - taker_status = serializers.BooleanField( + taker_status = serializers.CharField( required=False, - help_text="True if you are either a taker or maker, False otherwise", + help_text="Status of the maker:\n" + "- **'Active'** (seen within last 2 min)\n" + "- **'Seen Recently'** (seen within last 10 min)\n" + "- **'Inactive'** (seen more than 10 min ago)\n\n" + "Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty", ) - price_now = serializers.IntegerField( + price_now = serializers.FloatField( required=False, help_text="Price of the order in the order's currency at the time of request (upto 5 significant digits)", ) - premium = serializers.IntegerField( + premium = serializers.CharField( + required=False, help_text="Premium over the CEX price set by the maker" + ) + premium_now = serializers.FloatField( required=False, help_text="Premium over the CEX price at the current time" ) - premium_percentile = serializers.IntegerField( + premium_percentile = serializers.FloatField( required=False, help_text="(Only if `is_maker`) Premium percentile of your order compared to other public orders in the same currency currently in the order book", ) @@ -300,7 +325,11 @@ class OrderDetailSerializer(serializers.ModelSerializer): ) maker_summary = SummarySerializer(required=False) taker_summary = SummarySerializer(required=False) - platform_summary = PlatformSummarySerializer(required=True) + satoshis_now = serializers.IntegerField( + required=False, + help_text="Maximum size of the order right now in Satoshis", + ) + platform_summary = PlatformSummarySerializer(required=False) expiry_message = serializers.CharField( required=False, help_text="The reason the order expired (message associated with the `expiry_reason`)", @@ -338,7 +367,9 @@ class Meta: "payment_method", "is_explicit", "premium", + "premium_now", "satoshis", + "satoshis_now", "maker", "taker", "escrow_duration", @@ -350,7 +381,6 @@ class Meta: "maker_status", "taker_status", "price_now", - "premium", "premium_percentile", "num_similar_orders", "tg_enabled", @@ -441,7 +471,7 @@ class Meta: "satoshis_now", "bond_size", "latitude", - "longitude" + "longitude", ) @@ -482,7 +512,7 @@ class Meta: "escrow_duration", "bond_size", "latitude", - "longitude" + "longitude", ) diff --git a/api/tests/test_utils.py b/api/tests/test_utils.py index b1d9d9131..e5c4e7ddc 100644 --- a/api/tests/test_utils.py +++ b/api/tests/test_utils.py @@ -95,25 +95,21 @@ def test_get_exchange_rates(self, mock_get_session, mock_config): mock_response_blockchain.json.assert_called_once() mock_response_yadio.json.assert_called_once() - LNVENDOR = config("LNVENDOR", cast=str, default="LND") + if config("LNVENDOR", cast=str) == "LND": - if LNVENDOR == "LND": - - @patch("api.lightning.lnd.LNDNode.get_version") - def test_get_lnd_version(self, mock_get_version): - mock_get_version.return_value = "v0.17.0-beta" + def test_get_lnd_version(self): version = get_lnd_version() - self.assertEqual(version, "v0.17.0-beta") + self.assertTrue(isinstance(version, str)) - elif LNVENDOR == "CLN": + elif config("LNVENDOR", cast=str) == "CLN": - @patch("api.lightning.cln.CLNNode.get_version") - def test_get_cln_version(self, mock_get_version): - mock_get_version.return_value = "v23.08.1" + def test_get_cln_version(self): version = get_cln_version() - self.assertEqual(version, "v23.08.1") + self.assertTrue(isinstance(version, str)) - @patch("builtins.open", new_callable=mock_open, read_data="test_commit_hash") + @patch( + "builtins.open", new_callable=mock_open, read_data="00000000000000000000 dev" + ) def test_get_robosats_commit(self, mock_file): # Call the get_robosats_commit function commit_hash = get_robosats_commit() diff --git a/api/urls.py b/api/urls.py index 407dc2d0f..7d8b19c68 100644 --- a/api/urls.py +++ b/api/urls.py @@ -20,19 +20,20 @@ urlpatterns = [ path("schema/", SpectacularAPIView.as_view(), name="schema"), path("", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), - path("make/", MakerView.as_view()), + path("make/", MakerView.as_view(), name="make"), path( "order/", OrderView.as_view({"get": "get", "post": "take_update_confirm_dispute_cancel"}), + name="order", ), - path("robot/", RobotView.as_view()), - path("book/", BookView.as_view()), - path("info/", InfoView.as_view()), - path("price/", PriceView.as_view()), - path("limits/", LimitView.as_view()), - path("reward/", RewardView.as_view()), - path("historical/", HistoricalView.as_view()), - path("ticks/", TickView.as_view()), - path("stealth/", StealthView.as_view()), - path("chat/", ChatView.as_view({"get": "get", "post": "post"})), + path("robot/", RobotView.as_view(), name="robot"), + path("book/", BookView.as_view(), name="book"), + path("info/", InfoView.as_view({"get": "get"}), name="info"), + path("price/", PriceView.as_view(), name="price"), + path("limits/", LimitView.as_view(), name="limits"), + path("reward/", RewardView.as_view(), name="reward"), + path("historical/", HistoricalView.as_view(), name="historical"), + path("ticks/", TickView.as_view(), name="ticks"), + path("stealth/", StealthView.as_view(), name="stealth"), + path("chat/", ChatView.as_view({"get": "get", "post": "post"}), name="chat"), ] diff --git a/api/utils.py b/api/utils.py index 6e24f586b..4348892b7 100644 --- a/api/utils.py +++ b/api/utils.py @@ -176,12 +176,15 @@ def get_exchange_rates(currencies): @ring.dict(lnd_version_cache, expire=3600) def get_lnd_version(): - try: - from api.lightning.lnd import LNDNode + if LNVENDOR == "LND": + try: + from api.lightning.lnd import LNDNode - return LNDNode.get_version() - except Exception: - return None + return LNDNode.get_version() + except Exception: + return "Not installed" + else: + return "Not installed" cln_version_cache = {} @@ -189,12 +192,15 @@ def get_lnd_version(): @ring.dict(cln_version_cache, expire=3600) def get_cln_version(): - try: - from api.lightning.cln import CLNNode + if LNVENDOR == "CLN": + try: + from api.lightning.cln import CLNNode - return CLNNode.get_version() - except Exception: - return None + return CLNNode.get_version() + except Exception: + return "Not installed" + else: + return "Not installed" robosats_commit_cache = {} diff --git a/api/views.py b/api/views.py index 2239d18af..ae4bf9ace 100644 --- a/api/views.py +++ b/api/views.py @@ -724,7 +724,7 @@ def get(self, request, format=None): return Response(book_data, status=status.HTTP_200_OK) -class InfoView(ListAPIView): +class InfoView(viewsets.ViewSet): serializer_class = InfoSerializer @extend_schema(**InfoViewSchema.get) diff --git a/docker-tests.yml b/docker-tests.yml new file mode 100644 index 000000000..89ee98664 --- /dev/null +++ b/docker-tests.yml @@ -0,0 +1,177 @@ +# Spin up a regtest lightning network to run integration tests: +# docker-compose -f docker-tests.yml --env-file tests/compose.env up -d + +# Some useful handy commands that hopefully are never needed + +# docker exec -it btc bitcoin-cli -chain=regtest -rpcpassword=test -rpcuser=test createwallet default +# docker exec -it btc bitcoin-cli -chain=regtest -rpcpassword=test -rpcuser=test -generate 101 + +# docker exec -it coordinator-LND lncli --network=regtest getinfo +# docker exec -it robot-LND lncli --network=regtest --rpcserver localhost:10010 getinfo + +version: '3.9' +services: + bitcoind: + image: ruimarinho/bitcoin-core:${BITCOIND_VERSION:-24.0.1}-alpine + container_name: btc + restart: always + ports: + - "8000:8000" + - "8080:8080" + - "8081:8081" + - "10009:10009" + - "10010:10010" + - "9999:9999" + - "9998:9998" + - "5432:5432" + - "6379:6379" + volumes: + - bitcoin:/bitcoin/.bitcoin/ + command: + --txindex=1 + --printtoconsole + --regtest=1 + --server=1 + --rest=1 + --rpcuser=test + --rpcpassword=test + --logips=1 + --debug=1 + --rpcport=18443 + --rpcallowip=172.0.0.0/8 + --rpcallowip=192.168.0.0/16 + --zmqpubrawblock=tcp://0.0.0.0:28332 + --zmqpubrawtx=tcp://0.0.0.0:28333 + --listenonion=0 + + coordinator-LND: + image: lightninglabs/lnd:${LND_VERSION:-v0.17.0-beta} + container_name: coordinator-LND + restart: always + volumes: + - bitcoin:/root/.bitcoin/ + - lnd:/home/lnd/.lnd + - lnd:/root/.lnd + command: + --noseedbackup + --nobootstrap + --restlisten=localhost:8081 + --debuglevel=debug + --maxpendingchannels=10 + --rpclisten=0.0.0.0:10009 + --listen=0.0.0.0:9735 + --no-rest-tls + --color=#4126a7 + --alias=RoboSats + --bitcoin.active + --bitcoin.regtest + --bitcoin.node=bitcoind + --bitcoind.rpchost=127.0.0.1 + --bitcoind.rpcuser=test + --bitcoind.rpcpass=test + --bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332 + --bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333 + --protocol.wumbo-channels + depends_on: + - bitcoind + network_mode: service:bitcoind + + coordinator-CLN: + image: elementsproject/lightningd:${CLN_VERSION:-v23.08.1} + restart: always + container_name: coordinator-CLN + environment: + LIGHTNINGD_NETWORK: 'regtest' + volumes: + - cln:/root/.lightning + - ./docker/cln/plugins/cln-grpc-hold:/root/.lightning/plugins/cln-grpc-hold + - bitcoin:/root/.bitcoin + command: --regtest --wumbo --bitcoin-rpcuser=test --bitcoin-rpcpassword=test --rest-host=0.0.0.0 --bind-addr=127.0.0.1:9737 --grpc-port=9999 --grpc-hold-port=9998 --important-plugin=/root/.lightning/plugins/cln-grpc-hold --database-upgrade=true + depends_on: + - bitcoind + network_mode: service:bitcoind + + robot-LND: + image: lightninglabs/lnd:${LND_VERSION:-v0.17.0-beta} + container_name: robot-LND + restart: always + volumes: + - bitcoin:/root/.bitcoin/ + - lndrobot:/home/lnd/.lnd + - lndrobot:/root/.lnd + command: + --noseedbackup + --nobootstrap + --restlisten=localhost:8080 + --no-rest-tls + --debuglevel=debug + --maxpendingchannels=10 + --rpclisten=0.0.0.0:10010 + --listen=0.0.0.0:9736 + --color=#4126a7 + --alias=Robot + --bitcoin.active + --bitcoin.regtest + --bitcoin.node=bitcoind + --bitcoind.rpchost=127.0.0.1 + --bitcoind.rpcuser=test + --bitcoind.rpcpass=test + --bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332 + --bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333 + --protocol.wumbo-channels + depends_on: + - bitcoind + network_mode: service:bitcoind + + redis: + image: redis:${REDIS_VERSION:-7.2.1}-alpine + container_name: redis + restart: always + volumes: + - redisdata:/data + network_mode: service:bitcoind + + coordinator: + build: . + image: robosats-image + container_name: coordinator + restart: always + environment: + DEVELOPMENT: True + TESTING: True + USE_TOR: False + MACAROON_PATH: 'data/chain/bitcoin/regtest/admin.macaroon' + CLN_DIR: '/cln/regtest/' + env_file: + - ${ROBOSATS_ENVS_FILE} + depends_on: + - redis + - postgres + network_mode: service:bitcoind + volumes: + - .:/usr/src/robosats + - lnd:/lnd + - lndrobot:/lndrobot + - cln:/cln + healthcheck: + test: ["CMD", "curl", "localhost:8000"] + interval: 5s + timeout: 5s + retries: 3 + + postgres: + image: postgres:${POSTGRES_VERSION:-14.2}-alpine + container_name: sql + restart: always + environment: + POSTGRES_PASSWORD: 'example' + POSTGRES_USER: 'postgres' + POSTGRES_DB: 'postgres' + network_mode: service:bitcoind + +volumes: + redisdata: + bitcoin: + lnd: + cln: + lndrobot: \ No newline at end of file diff --git a/docker/cln/plugins/cln-grpc-hold b/docker/cln/plugins/cln-grpc-hold new file mode 100755 index 000000000..7a0d46532 Binary files /dev/null and b/docker/cln/plugins/cln-grpc-hold differ diff --git a/docker/lnd/Dockerfile b/docker/lnd/Dockerfile index e6c0bd276..3093169be 100644 --- a/docker/lnd/Dockerfile +++ b/docker/lnd/Dockerfile @@ -1,4 +1,4 @@ -FROM lightninglabs/lnd:v0.16.3-beta +FROM lightninglabs/lnd:v0.17.0-beta ARG LOCAL_USER_ID=9999 ARG LOCAL_GROUP_ID=9999 diff --git a/docs/_includes/api-v0.5.3.html b/docs/_includes/api-latest.html similarity index 96% rename from docs/_includes/api-v0.5.3.html rename to docs/_includes/api-latest.html index d1987bc02..a3cfe7c0d 100644 --- a/docs/_includes/api-v0.5.3.html +++ b/docs/_includes/api-latest.html @@ -1,5 +1,5 @@ API' nav: docs -src: "_pages/docs/03-understand/11-api-v0.5.3.md" +src: "_pages/docs/03-understand/11-api-latest.md" --- -{% include api-v0.5.3.html %} \ No newline at end of file +{% include api-latest.html %} \ No newline at end of file diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml new file mode 100644 index 000000000..3599b2d43 --- /dev/null +++ b/docs/assets/schemas/api-latest.yaml @@ -0,0 +1,2013 @@ +openapi: 3.0.3 +info: + title: RoboSats REST API + version: 0.5.3 + x-logo: + url: https://raw.githubusercontent.com/Reckless-Satoshi/robosats/main/frontend/static/assets/images/robosats-0.1.1-banner.png + backgroundColor: '#FFFFFF' + altText: RoboSats logo + description: |2+ + + REST API Documentation for [RoboSats](https://learn.robosats.com) - A Simple and Private LN P2P Exchange + +

+ Note: + The RoboSats REST API is on v0, which in other words, is beta. + We recommend that if you don't have time to actively maintain + your project, do not build it with v0 of the API. A refactored, simpler + and more stable version - v1 will be released soonâ„¢. +

+ +paths: + /api/book/: + get: + operationId: book_list + description: Get public orders in the book. + summary: Get public orders + parameters: + - in: query + name: currency + schema: + type: integer + description: The currency id to filter by. Currency IDs can be found [here](https://github.com/RoboSats/robosats/blob/main/frontend/static/assets/currencies.json). + Value of `0` means ANY currency + - in: query + name: type + schema: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + Order type to filter by + - `0` - BUY + - `1` - SELL + - `2` - ALL + tags: + - book + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrderPublic' + description: '' + /api/chat/: + get: + operationId: chat_retrieve + description: Returns chat messages for an order with an index higher than `offset`. + tags: + - chat + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PostMessage' + description: '' + post: + operationId: chat_create + description: Adds one new message to the chatroom. + tags: + - chat + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostMessage' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PostMessage' + multipart/form-data: + schema: + $ref: '#/components/schemas/PostMessage' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PostMessage' + description: '' + /api/historical/: + get: + operationId: historical_list + description: Get historical exchange activity. Currently, it lists each day's + total contracts and their volume in BTC since inception. + summary: Get historical exchange activity + tags: + - historical + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: + type: object + properties: + volume: + type: integer + description: Total Volume traded on that particular date + num_contracts: + type: number + description: Number of successful trades on that particular + date + examples: + TruncatedExample: + value: + - : + code: USD + price: '42069.69' + min_amount: '4.2' + max_amount: '420.69' + summary: Truncated example + description: '' + /api/info/: + get: + operationId: info_retrieve + description: |2 + + Get general info (overview) about the exchange. + + **Info**: + - Current market data + - num. of orders + - book liquidity + - 24h active robots + - 24h non-KYC premium + - 24h volume + - all time volume + - Node info + - lnd version + - node id + - node alias + - network + - Fees + - maker and taker fees + - on-chain swap fees + summary: Get info + tags: + - info + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Info' + description: '' + /api/limits/: + get: + operationId: limits_list + description: Get a list of order limits for every currency pair available. + summary: List order limits + tags: + - limits + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: + type: object + properties: + code: + type: string + description: Three letter currency symbol + price: + type: integer + min_amount: + type: integer + description: Minimum amount allowed in an order in the particular + currency + max_amount: + type: integer + description: Maximum amount allowed in an order in the particular + currency + examples: + TruncatedExample.RealResponseContainsAllTheCurrencies: + value: + - : + code: USD + price: '42069.69' + min_amount: '4.2' + max_amount: '420.69' + summary: Truncated example. Real response contains all the currencies + description: '' + /api/make/: + post: + operationId: make_create + description: |2 + + Create a new order as a maker. + + + Default values for the following fields if not specified: + - `public_duration` - **24** + - `escrow_duration` - **180** + - `bond_size` - **3.0** + - `has_range` - **false** + - `premium` - **0** + summary: Create a maker order + tags: + - make + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MakeOrder' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MakeOrder' + multipart/form-data: + schema: + $ref: '#/components/schemas/MakeOrder' + required: true + security: + - tokenAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ListOrder' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' + '409': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' + /api/order/: + get: + operationId: order_retrieve + description: |2+ + + Get the order details. Details include/exclude attributes according to what is the status of the order + + The following fields are available irrespective of whether you are a participant or not (A participant is either a taker or a maker of an order) + All the other fields are only available when you are either the taker or the maker of the order: + + - `id` + - `status` + - `created_at` + - `expires_at` + - `type` + - `currency` + - `amount` + - `has_range` + - `min_amount` + - `max_amount` + - `payment_method` + - `is_explicit` + - `premium` + - `satoshis` + - `maker` + - `taker` + - `escrow_duration` + - `total_secs_exp` + - `penalty` + - `is_maker` + - `is_taker` + - `is_participant` + - `maker_status` + - `taker_status` + - `price_now` + + ### Order Status + + The response of this route changes according to the status of the order. Some fields are documented below (check the 'Responses' section) + with the status code of when they are available and some or not. With v1 API we aim to simplify this + route to make it easier to understand which fields are available on which order status codes. + + `status` specifies the status of the order. Below is a list of possible values (status codes) and what they mean: + - `0` "Waiting for maker bond" + - `1` "Public" + - `2` "Paused" + - `3` "Waiting for taker bond" + - `4` "Cancelled" + - `5` "Expired" + - `6` "Waiting for trade collateral and buyer invoice" + - `7` "Waiting only for seller trade collateral" + - `8` "Waiting only for buyer invoice" + - `9` "Sending fiat - In chatroom" + - `10` "Fiat sent - In chatroom" + - `11` "In dispute" + - `12` "Collaboratively cancelled" + - `13` "Sending satoshis to buyer" + - `14` "Sucessful trade" + - `15` "Failed lightning network routing" + - `16` "Wait for dispute resolution" + - `17` "Maker lost dispute" + - `18` "Taker lost dispute" + + + Notes: + - both `price_now` and `premium_now` are always calculated irrespective of whether `is_explicit` = true or false + + summary: Get order details + parameters: + - in: query + name: order_id + schema: + type: integer + required: true + tags: + - order + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderDetail' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + examples: + OrderCancelled: + value: + bad_request: This order has been cancelled collaborativelly + summary: Order cancelled + WhenTheOrderIsNotPublicAndYouNeitherTheTakerNorMaker: + value: + bad_request: This order is not available + summary: When the order is not public and you neither the taker + nor maker + WhenMakerBondExpires(asMaker): + value: + bad_request: Invoice expired. You did not confirm publishing the + order in time. Make a new order. + summary: When maker bond expires (as maker) + WhenRobosatsNodeIsDown: + value: + bad_request: The Lightning Network Daemon (LND) is down. Write + in the Telegram group to make sure the staff is aware. + summary: When Robosats node is down + description: '' + '403': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + default: This order is not available + description: '' + '404': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + default: Invalid order Id + description: '' + post: + operationId: order_create + description: |2+ + + Update an order + + `action` field is required and determines what is to be done. Below + is an explanation of what each action does: + + - `take` + - If the order has not expired and is still public, on a + successful take, you get the same response as if `GET /order` + was called and the status of the order was `3` (waiting for + taker bond) which means `bond_satoshis` and `bond_invoice` are + present in the response as well. Once the `bond_invoice` is + paid, you successfully become the taker of the order and the + status of the order changes. + - `pause` + - Toggle the status of an order from `1` to `2` and vice versa. Allowed only if status is `1` (Public) or `2` (Paused) + - `update_invoice` + - This action only is valid if you are the buyer. The `invoice` + field needs to be present in the body and the value must be a + valid LN invoice as cleartext PGP message signed with the robot key. Make sure to perform this action only when + both the bonds are locked. i.e The status of your order is + at least `6` (Waiting for trade collateral and buyer invoice) + - `update_address` + - This action is only valid if you are the buyer. This action is + used to set an on-chain payout address if you wish to have your + payout be received on-chain. Only valid if there is an address in the body as + cleartext PGP message signed with the robot key. This enables on-chain swap for the + order, so even if you earlier had submitted a LN invoice, it + will be ignored. You get to choose the `mining_fee_rate` as + well. Mining fee rate is specified in sats/vbyte. + - `cancel` + - This action is used to cancel an existing order. You cannot cancel an order if it's in one of the following states: + - `1` - Cancelled + - `5` - Expired + - `11` - In dispute + - `12` - Collaboratively cancelled + - `13` - Sending satoshis to buyer + - `14` - Successful trade + - `15` - Failed lightning network routing + - `17` - Maker lost dispute + - `18` - Taker lost dispute + + Note that there are penalties involved for cancelling a order + mid-trade so use this action carefully: + + - As a maker if you cancel an order after you have locked your + maker bond, you are returned your bond. This may change in + the future to prevent DDoSing the LN node and you won't be + returned the maker bond. + - As a taker there is a time penalty involved if you `take` an + order and cancel it without locking the taker bond. + - For both taker or maker, if you cancel the order when both + have locked their bonds (status = `6` or `7`), you loose your + bond and a percent of it goes as "rewards" to your + counterparty and some of it the platform keeps. This is to + discourage wasting time and DDoSing the platform. + - For both taker or maker, if you cancel the order when the + escrow is locked (status = `8` or `9`), you trigger a + collaborative cancel request. This sets + `(m|t)aker_asked_cancel` field to `true` depending on whether + you are the maker or the taker respectively, so that your + counterparty is informed that you asked for a cancel. + - For both taker or maker, and your counterparty asked for a + cancel (i.e `(m|t)aker_asked_cancel` is true), and you cancel + as well, a collaborative cancel takes place which returns + both the bonds and escrow to the respective parties. Note + that in the future there will be a cost for even + collaborativelly cancelling orders for both parties. + - `confirm` + - This is a **crucial** action. This confirms the sending and + receiving of fiat depending on whether you are a buyer or + seller. There is not much RoboSats can do to actually confirm + and verify the fiat payment channel. It is up to you to make + sure of the correct amount was received before you confirm. + This action is only allowed when status is either `9` (Sending + fiat - In chatroom) or `10` (Fiat sent - In chatroom) + - If you are the buyer, it simply sets `fiat_sent` to `true` + which means that you have sent the fiat using the payment + method selected by the seller and signals the seller that the + fiat payment was done. + - If you are the seller, be very careful and double check + before performing this action. Check that your fiat payment + method was successful in receiving the funds and whether it + was the correct amount. This action settles the escrow and + pays the buyer and sets the the order status to `13` (Sending + satohis to buyer) and eventually to `14` (successful trade). + - `undo_confirm` + - This action will undo the fiat_sent confirmation by the buyer + it is allowed only once the fiat is confirmed as sent and can + enable the collaborative cancellation option if an off-robosats + payment cannot be completed or is blocked. + - `dispute` + - This action is allowed only if status is `9` or `10`. It sets + the order status to `11` (In dispute) and sets `is_disputed` to + `true`. Both the bonds and the escrow are settled (i.e RoboSats + takes custody of the funds). Disputes can take long to resolve, + it might trigger force closure for unresolved HTLCs). Dispute + winner will have to submit a new invoice for value of escrow + + bond. + - `submit_statement` + - This action updates the dispute statement. Allowed only when + status is `11` (In dispute). `statement` must be sent in the + request body and should be a string. 100 chars < length of + `statement` < 5000 chars. You need to describe the reason for + raising a dispute. The `(m|t)aker_statement` field is set + respectively. Only when both parties have submitted their + dispute statement, the order status changes to `16` (Waiting + for dispute resolution) + - `rate_platform` + - Let us know how much you love (or hate 😢) RoboSats. + You can rate the platform from `1-5` using the `rate` field in the request body + + summary: Update order + parameters: + - in: query + name: order_id + schema: + type: integer + required: true + tags: + - order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateOrder' + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UpdateOrder' + multipart/form-data: + schema: + $ref: '#/components/schemas/UpdateOrder' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderDetail' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + description: '' + /api/price/: + get: + operationId: price_list + description: Get the last market price for each currency. Also, returns some + more info about the last trade in each currency. + summary: Get last market prices + tags: + - price + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: + type: object + properties: + price: + type: integer + volume: + type: integer + premium: + type: integer + timestamp: + type: string + format: date-time + examples: + TruncatedExample.RealResponseContainsAllTheCurrencies: + value: + - : + price: 21948.89 + volume: 0.01366812 + premium: 3.5 + timestamp: '2022-09-13T14:32:40.591774Z' + summary: Truncated example. Real response contains all the currencies + description: '' + /api/reward/: + post: + operationId: reward_create + description: Withdraw user reward by submitting an invoice. The invoice must + be send as cleartext PGP message signed with the robot key + summary: Withdraw reward + tags: + - reward + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimReward' + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + WhenNoRewardsEarned: + value: + successful_withdrawal: false + bad_invoice: You have not earned rewards + summary: When no rewards earned + BadInvoiceOrInCaseOfPaymentFailure: + value: + successful_withdrawal: false + bad_invoice: Does not look like a valid lightning invoice + summary: Bad invoice or in case of payment failure + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ClaimReward' + multipart/form-data: + schema: + $ref: '#/components/schemas/ClaimReward' + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + successful_withdrawal: + type: boolean + default: true + description: '' + '400': + content: + application/json: + schema: + oneOf: + - type: object + properties: + successful_withdrawal: + type: boolean + default: false + bad_invoice: + type: string + description: More context for the reason of the failure + - type: object + properties: + successful_withdrawal: + type: boolean + default: false + bad_request: + type: string + description: More context for the reason of the failure + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + WhenNoRewardsEarned: + value: + successful_withdrawal: false + bad_invoice: You have not earned rewards + summary: When no rewards earned + BadInvoiceOrInCaseOfPaymentFailure: + value: + successful_withdrawal: false + bad_invoice: Does not look like a valid lightning invoice + summary: Bad invoice or in case of payment failure + description: '' + /api/robot/: + get: + operationId: robot_retrieve + description: |2+ + + Get robot info 🤖 + + An authenticated request (has the token's sha256 hash encoded as base 91 in the Authorization header) will be + returned the information about the state of a robot. + + Make sure you generate your token using cryptographically secure methods. [Here's]() the function the Javascript + client uses to generate the tokens. Since the server only receives the hash of the + token, it is responsibility of the client to create a strong token. Check + [here](https://github.com/RoboSats/robosats/blob/main/frontend/src/utils/token.js) + to see how the Javascript client creates a random strong token and how it validates entropy is optimal for tokens + created by the user at will. + + `public_key` - PGP key associated with the user (Armored ASCII format) + `encrypted_private_key` - Private PGP key. This is only stored on the backend for later fetching by + the frontend and the key can't really be used by the server since it's protected by the token + that only the client knows. Will be made an optional parameter in a future release. + On the Javascript client, It's passphrase is set to be the secret token generated. + + A gpg key can be created by: + + ```shell + gpg --full-gen-key + ``` + + it's public key can be exported in ascii armored format with: + + ```shell + gpg --export --armor + ``` + + and it's private key can be exported in ascii armored format with: + + ```shell + gpg --export-secret-keys --armor + ``` + + summary: Get robot info + tags: + - robot + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + encrypted_private_key: + type: string + description: Armored ASCII PGP private key block + nickname: + type: string + description: Username generated (Robot name) + public_key: + type: string + description: Armored ASCII PGP public key block + wants_stealth: + type: boolean + default: false + description: Whether the user prefers stealth invoices + found: + type: boolean + description: Robot had been created in the past. Only if the robot + was created +5 mins ago. + tg_enabled: + type: boolean + description: The robot has telegram notifications enabled + tg_token: + type: string + description: Token to enable telegram with /start + tg_bot_name: + type: string + description: Name of the coordinator's telegram bot + active_order_id: + type: integer + description: Active order id if present + last_order_id: + type: integer + description: Last order id if present + earned_rewards: + type: integer + description: Satoshis available to be claimed + last_login: + type: string + format: date-time + nullable: true + description: Last time the coordinator saw this robot + examples: + SuccessfullyRetrievedRobot: + value: + nickname: SatoshiNakamoto21 + public_key: |- + -----BEGIN PGP PUBLIC KEY BLOCK----- + + ...... + ...... + encrypted_private_key: |- + -----BEGIN PGP PRIVATE KEY BLOCK----- + + ...... + ...... + wants_stealth: true + summary: Successfully retrieved robot + description: '' + /api/stealth/: + post: + operationId: stealth_create + description: Update stealth invoice option for the user + summary: Update stealth option + tags: + - stealth + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Stealth' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Stealth' + multipart/form-data: + schema: + $ref: '#/components/schemas/Stealth' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Stealth' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' + /api/ticks/: + get: + operationId: ticks_list + description: |- + Get all market ticks. Returns a list of all the market ticks since inception. + CEX price is also recorded for useful insight on the historical premium of Non-KYC BTC. Price is set when taker bond is locked. + summary: Get market ticks + parameters: + - in: query + name: end + schema: + type: string + description: End date formatted as DD-MM-YYYY + - in: query + name: start + schema: + type: string + description: Start date formatted as DD-MM-YYYY + tags: + - ticks + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tick' + description: '' +components: + schemas: + ActionEnum: + enum: + - pause + - take + - update_invoice + - update_address + - submit_statement + - dispute + - cancel + - confirm + - undo_confirm + - rate_platform + type: string + description: |- + * `pause` - pause + * `take` - take + * `update_invoice` - update_invoice + * `update_address` - update_address + * `submit_statement` - submit_statement + * `dispute` - dispute + * `cancel` - cancel + * `confirm` - confirm + * `undo_confirm` - undo_confirm + * `rate_platform` - rate_platform + BlankEnum: + enum: + - '' + ClaimReward: + type: object + properties: + invoice: + type: string + nullable: true + description: A valid LN invoice with the reward amount to withdraw + maxLength: 2000 + CurrencyEnum: + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 26 + - 27 + - 28 + - 29 + - 30 + - 31 + - 32 + - 33 + - 34 + - 35 + - 36 + - 37 + - 38 + - 39 + - 40 + - 41 + - 42 + - 43 + - 44 + - 45 + - 46 + - 47 + - 48 + - 49 + - 50 + - 51 + - 52 + - 53 + - 54 + - 55 + - 56 + - 57 + - 58 + - 59 + - 60 + - 61 + - 62 + - 63 + - 64 + - 65 + - 66 + - 67 + - 68 + - 69 + - 70 + - 71 + - 72 + - 73 + - 74 + - 75 + - 300 + - 1000 + type: integer + description: |- + * `1` - USD + * `2` - EUR + * `3` - JPY + * `4` - GBP + * `5` - AUD + * `6` - CAD + * `7` - CHF + * `8` - CNY + * `9` - HKD + * `10` - NZD + * `11` - SEK + * `12` - KRW + * `13` - SGD + * `14` - NOK + * `15` - MXN + * `16` - BYN + * `17` - RUB + * `18` - ZAR + * `19` - TRY + * `20` - BRL + * `21` - CLP + * `22` - CZK + * `23` - DKK + * `24` - HRK + * `25` - HUF + * `26` - INR + * `27` - ISK + * `28` - PLN + * `29` - RON + * `30` - ARS + * `31` - VES + * `32` - COP + * `33` - PEN + * `34` - UYU + * `35` - PYG + * `36` - BOB + * `37` - IDR + * `38` - ANG + * `39` - CRC + * `40` - CUP + * `41` - DOP + * `42` - GHS + * `43` - GTQ + * `44` - ILS + * `45` - JMD + * `46` - KES + * `47` - KZT + * `48` - MYR + * `49` - NAD + * `50` - NGN + * `51` - AZN + * `52` - PAB + * `53` - PHP + * `54` - PKR + * `55` - QAR + * `56` - SAR + * `57` - THB + * `58` - TTD + * `59` - VND + * `60` - XOF + * `61` - TWD + * `62` - TZS + * `63` - XAF + * `64` - UAH + * `65` - EGP + * `66` - LKR + * `67` - MAD + * `68` - AED + * `69` - TND + * `70` - ETB + * `71` - GEL + * `72` - UGX + * `73` - RSD + * `74` - IRT + * `75` - BDT + * `300` - XAU + * `1000` - BTC + ExpiryReasonEnum: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + description: |- + * `0` - Expired not taken + * `1` - Maker bond not locked + * `2` - Escrow not locked + * `3` - Invoice not submitted + * `4` - Neither escrow locked or invoice submitted + Info: + type: object + properties: + num_public_buy_orders: + type: integer + num_public_sell_orders: + type: integer + book_liquidity: + type: integer + description: Total amount of BTC in the order book + active_robots_today: + type: integer + last_day_nonkyc_btc_premium: + type: number + format: double + description: Average premium (weighted by volume) of the orders in the last + 24h + last_day_volume: + type: number + format: double + description: Total volume in BTC in the last 24h + lifetime_volume: + type: number + format: double + description: Total volume in BTC since exchange's inception + lnd_version: + type: string + cln_version: + type: string + robosats_running_commit_hash: + type: string + alternative_site: + type: string + alternative_name: + type: string + node_alias: + type: string + node_id: + type: string + network: + type: string + maker_fee: + type: number + format: double + description: Exchange's set maker fee + taker_fee: + type: number + format: double + description: 'Exchange''s set taker fee ' + bond_size: + type: number + format: double + description: Default bond size (percent) + current_swap_fee_rate: + type: number + format: double + description: Swap fees to perform on-chain transaction (percent) + version: + $ref: '#/components/schemas/Version' + notice_severity: + $ref: '#/components/schemas/NoticeSeverityEnum' + notice_message: + type: string + required: + - active_robots_today + - alternative_name + - alternative_site + - bond_size + - book_liquidity + - cln_version + - current_swap_fee_rate + - last_day_nonkyc_btc_premium + - last_day_volume + - lifetime_volume + - lnd_version + - maker_fee + - network + - node_alias + - node_id + - notice_message + - notice_severity + - num_public_buy_orders + - num_public_sell_orders + - robosats_running_commit_hash + - taker_fee + - version + ListOrder: + type: object + properties: + id: + type: integer + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/StatusEnum' + minimum: 0 + maximum: 32767 + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + nullable: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + maxLength: 70 + is_explicit: + type: boolean + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + maker: + type: integer + nullable: true + taker: + type: integer + nullable: true + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + latitude: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,6})?$ + nullable: true + longitude: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ + nullable: true + required: + - expires_at + - id + - type + MakeOrder: + type: object + properties: + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + description: Currency id. See [here](https://github.com/RoboSats/robosats/blob/main/frontend/static/assets/currencies.json) + for a list of all IDs + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + default: false + description: |- + Whether the order specifies a range of amount or a fixed amount. + + If `true`, then `min_amount` and `max_amount` fields are **required**. + + If `false` then `amount` is **required** + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + default: not specified + description: Can be any string. The UI recognizes [these payment methods](https://github.com/RoboSats/robosats/blob/main/frontend/src/components/payment-methods/Methods.js) + and displays them with a logo. + maxLength: 70 + is_explicit: + type: boolean + default: false + description: Whether the order is explicitly priced or not. If set to `true` + then `satoshis` need to be specified + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + public_duration: + type: integer + maximum: 86400 + minimum: 597.6 + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + latitude: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,6})?$ + nullable: true + longitude: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ + nullable: true + required: + - currency + - type + Nested: + type: object + properties: + id: + type: integer + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyEnum' + minimum: 0 + maximum: 32767 + exchange_rate: + type: string + format: decimal + pattern: ^-?\d{0,14}(?:\.\d{0,4})?$ + nullable: true + timestamp: + type: string + format: date-time + required: + - currency + - id + NoticeSeverityEnum: + enum: + - none + - warning + - success + - error + - info + type: string + description: |- + * `none` - none + * `warning` - warning + * `success` - success + * `error` - error + * `info` - info + NullEnum: + enum: + - null + OrderDetail: + type: object + properties: + id: + type: integer + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/StatusEnum' + minimum: 0 + maximum: 32767 + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + nullable: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + maxLength: 70 + is_explicit: + type: boolean + premium: + type: string + description: Premium over the CEX price set by the maker + premium_now: + type: number + format: double + description: Premium over the CEX price at the current time + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + satoshis_now: + type: integer + description: Maximum size of the order right now in Satoshis + maker: + type: integer + nullable: true + taker: + type: integer + nullable: true + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + total_secs_exp: + type: integer + description: Duration of time (in seconds) to expire, according to the current + status of order.This is duration of time after `created_at` (in seconds) + that the order will automatically expire.This value changes according + to which stage the order is in + penalty: + type: string + format: date-time + description: Time when the user penalty will expire. Penalty applies when + you create orders repeatedly without commiting a bond + is_maker: + type: boolean + description: Whether you are the maker or not + is_taker: + type: boolean + description: Whether you are the taker or not + is_participant: + type: boolean + description: True if you are either a taker or maker, False otherwise + maker_status: + type: string + description: |- + Status of the maker: + - **'Active'** (seen within last 2 min) + - **'Seen Recently'** (seen within last 10 min) + - **'Inactive'** (seen more than 10 min ago) + + Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty + taker_status: + type: string + description: |- + Status of the maker: + - **'Active'** (seen within last 2 min) + - **'Seen Recently'** (seen within last 10 min) + - **'Inactive'** (seen more than 10 min ago) + + Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty + price_now: + type: number + format: double + description: Price of the order in the order's currency at the time of request + (upto 5 significant digits) + premium_percentile: + type: number + format: double + description: (Only if `is_maker`) Premium percentile of your order compared + to other public orders in the same currency currently in the order book + num_similar_orders: + type: integer + description: (Only if `is_maker`) The number of public orders of the same + currency currently in the order book + tg_enabled: + type: boolean + description: (Only if `is_maker`) Whether Telegram notification is enabled + or not + tg_token: + type: string + description: (Only if `is_maker`) Your telegram bot token required to enable + notifications. + tg_bot_name: + type: string + description: (Only if `is_maker`) The Telegram username of the bot + is_buyer: + type: boolean + description: Whether you are a buyer of sats (you will be receiving sats) + is_seller: + type: boolean + description: Whether you are a seller of sats or not (you will be sending + sats) + maker_nick: + type: string + description: Nickname (Robot name) of the maker + taker_nick: + type: string + description: Nickname (Robot name) of the taker + status_message: + type: string + description: The current status of the order corresponding to the `status` + is_fiat_sent: + type: boolean + description: Whether or not the fiat amount is sent by the buyer + is_disputed: + type: boolean + description: Whether or not the counterparty raised a dispute + ur_nick: + type: string + description: Your Nick + maker_locked: + type: boolean + description: True if maker bond is locked, False otherwise + taker_locked: + type: boolean + description: True if taker bond is locked, False otherwise + escrow_locked: + type: boolean + description: True if escrow is locked, False otherwise. Escrow is the sats + to be sold, held by Robosats until the trade is finised. + trade_satoshis: + type: integer + description: 'Seller sees the amount of sats they need to send. Buyer sees + the amount of sats they will receive ' + bond_invoice: + type: string + description: When `status` = `0`, `3`. Bond invoice to be paid + bond_satoshis: + type: integer + description: The bond amount in satoshis + escrow_invoice: + type: string + description: For the seller, the escrow invoice to be held by RoboSats + escrow_satoshis: + type: integer + description: The escrow amount in satoshis + invoice_amount: + type: integer + description: The amount in sats the buyer needs to submit an invoice of + to receive the trade amount + swap_allowed: + type: boolean + description: Whether on-chain swap is allowed + swap_failure_reason: + type: string + description: Reason for why on-chain swap is not available + suggested_mining_fee_rate: + type: integer + description: fee in sats/vbyte for the on-chain swap + swap_fee_rate: + type: number + format: double + description: in percentage, the swap fee rate the platform charges + pending_cancel: + type: boolean + description: Your counterparty requested for a collaborative cancel when + `status` is either `8`, `9` or `10` + asked_for_cancel: + type: boolean + description: You requested for a collaborative cancel `status` is either + `8`, `9` or `10` + statement_submitted: + type: boolean + description: True if you have submitted a statement. Available when `status` + is `11` + retries: + type: integer + description: Number of times ln node has tried to make the payment to you + (only if you are the buyer) + next_retry_time: + type: string + format: date-time + description: The next time payment will be retried. Payment is retried every + 1 sec + failure_reason: + type: string + description: The reason the payout failed + invoice_expired: + type: boolean + description: True if the payout invoice expired. `invoice_amount` will be + re-set and sent which means the user has to submit a new invoice to be + payed + public_duration: + type: integer + maximum: 86400 + minimum: 597.6 + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + trade_fee_percent: + type: integer + description: The fee for the trade (fees differ for maker and taker) + bond_size_sats: + type: integer + description: The size of the bond in sats + bond_size_percent: + type: integer + description: same as `bond_size` + maker_summary: + $ref: '#/components/schemas/Summary' + taker_summary: + $ref: '#/components/schemas/Summary' + platform_summary: + $ref: '#/components/schemas/PlatformSummary' + expiry_reason: + nullable: true + minimum: 0 + maximum: 32767 + oneOf: + - $ref: '#/components/schemas/ExpiryReasonEnum' + - $ref: '#/components/schemas/NullEnum' + expiry_message: + type: string + description: The reason the order expired (message associated with the `expiry_reason`) + num_satoshis: + type: integer + description: only if status = `14` (Successful Trade) and is_buyer = `true` + sent_satoshis: + type: integer + description: only if status = `14` (Successful Trade) and is_buyer = `true` + txid: + type: string + description: Transaction id of the on-chain swap payout. Only if status + = `14` (Successful Trade) and is_buyer = `true` + network: + type: string + description: The network eg. 'testnet', 'mainnet'. Only if status = `14` + (Successful Trade) and is_buyer = `true` + latitude: + type: number + format: double + description: Latitude of the order for F2F payments + longitude: + type: number + format: double + description: Longitude of the order for F2F payments + required: + - expires_at + - id + - type + OrderPublic: + type: object + properties: + id: + type: integer + readOnly: true + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + nullable: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + maxLength: 70 + is_explicit: + type: boolean + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + maker: + type: integer + nullable: true + maker_nick: + type: string + maker_status: + type: string + description: Status of the nick - "Active" or "Inactive" + price: + type: number + format: double + description: Price in order's fiat currency + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + satoshis_now: + type: integer + description: The amount of sats to be traded at the present moment (not + including the fees) + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + latitude: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,6})?$ + nullable: true + longitude: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ + nullable: true + required: + - expires_at + - id + - type + PlatformSummary: + type: object + properties: + contract_timestamp: + type: string + format: date-time + description: Timestamp of when the contract was finalized (price and sats + fixed) + contract_total_time: + type: number + format: double + description: The time taken for the contract to complete (from taker taking + the order to completion of order) in seconds + routing_fee_sats: + type: integer + description: Sats payed by the exchange for routing fees. Mining fee in + case of on-chain swap payout + trade_revenue_sats: + type: integer + description: The sats the exchange earned from the trade + PostMessage: + type: object + properties: + PGP_message: + type: string + nullable: true + maxLength: 5000 + order_id: + type: integer + minimum: 0 + description: Order ID of chatroom + offset: + type: integer + minimum: 0 + nullable: true + description: Offset for message index to get as response + required: + - order_id + RatingEnum: + enum: + - '1' + - '2' + - '3' + - '4' + - '5' + type: string + description: |- + * `1` - 1 + * `2` - 2 + * `3` - 3 + * `4` - 4 + * `5` - 5 + StatusEnum: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + type: integer + description: |- + * `0` - Waiting for maker bond + * `1` - Public + * `2` - Paused + * `3` - Waiting for taker bond + * `4` - Cancelled + * `5` - Expired + * `6` - Waiting for trade collateral and buyer invoice + * `7` - Waiting only for seller trade collateral + * `8` - Waiting only for buyer invoice + * `9` - Sending fiat - In chatroom + * `10` - Fiat sent - In chatroom + * `11` - In dispute + * `12` - Collaboratively cancelled + * `13` - Sending satoshis to buyer + * `14` - Sucessful trade + * `15` - Failed lightning network routing + * `16` - Wait for dispute resolution + * `17` - Maker lost dispute + * `18` - Taker lost dispute + Stealth: + type: object + properties: + wantsStealth: + type: boolean + required: + - wantsStealth + Summary: + type: object + properties: + sent_fiat: + type: integer + description: same as `amount` (only for buyer) + received_sats: + type: integer + description: same as `trade_satoshis` (only for buyer) + is_swap: + type: boolean + description: True if the payout was on-chain (only for buyer) + received_onchain_sats: + type: integer + description: The on-chain sats received (only for buyer and if `is_swap` + is `true`) + mining_fee_sats: + type: integer + description: Mining fees paid in satoshis (only for buyer and if `is_swap` + is `true`) + swap_fee_sats: + type: integer + description: Exchange swap fee in sats (i.e excluding miner fees) (only + for buyer and if `is_swap` is `true`) + swap_fee_percent: + type: number + format: double + description: same as `swap_fee_rate` (only for buyer and if `is_swap` is + `true` + sent_sats: + type: integer + description: The total sats you sent (only for seller) + received_fiat: + type: integer + description: same as `amount` (only for seller) + trade_fee_sats: + type: integer + description: Exchange fees in sats (Does not include swap fee and miner + fee) + Tick: + type: object + properties: + timestamp: + type: string + format: date-time + currency: + allOf: + - $ref: '#/components/schemas/Nested' + readOnly: true + volume: + type: string + format: decimal + nullable: true + price: + type: string + format: decimal + pattern: ^-?\d{0,14}(?:\.\d{0,2})?$ + nullable: true + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + fee: + type: string + format: decimal + required: + - currency + TypeEnum: + enum: + - 0 + - 1 + type: integer + description: |- + * `0` - BUY + * `1` - SELL + UpdateOrder: + type: object + properties: + invoice: + type: string + nullable: true + description: |+ + Invoice used for payouts. Must be PGP signed with the robot's public key. The expected Armored PGP header is -----BEGIN PGP SIGNED MESSAGE----- + Hash: SHA512 + + maxLength: 15000 + routing_budget_ppm: + type: integer + maximum: 100001 + minimum: 0 + nullable: true + default: 0 + description: Max budget to allocate for routing in PPM + address: + type: string + nullable: true + description: |+ + Onchain address used for payouts. Must be PGP signed with the robot's public key. The expected Armored PGP header is -----BEGIN PGP SIGNED MESSAGE----- + Hash: SHA512 + + maxLength: 15000 + statement: + type: string + nullable: true + maxLength: 500000 + action: + $ref: '#/components/schemas/ActionEnum' + rating: + nullable: true + oneOf: + - $ref: '#/components/schemas/RatingEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + mining_fee_rate: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,3})?$ + nullable: true + required: + - action + Version: + type: object + properties: + major: + type: integer + minor: + type: integer + patch: + type: integer + required: + - major + - minor + - patch + securitySchemes: + tokenAuth: + type: apiKey + in: header + name: Authorization + description: Token-based authentication with required prefix "Token" diff --git a/docs/assets/schemas/api-v0.5.3.yaml b/docs/assets/schemas/api-v0.5.3.yaml index 376333b4d..51d53a670 100644 --- a/docs/assets/schemas/api-v0.5.3.yaml +++ b/docs/assets/schemas/api-v0.5.3.yaml @@ -140,7 +140,7 @@ paths: description: '' /api/info/: get: - operationId: info_list + operationId: info_retrieve description: |2 Get general info (overview) about the exchange. @@ -172,9 +172,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Info' + $ref: '#/components/schemas/Info' description: '' /api/limits/: get: @@ -563,13 +561,7 @@ paths: content: application/json: schema: - type: object - additionalProperties: - oneOf: - - type: str - - type: number - - type: object - - type: boolean + $ref: '#/components/schemas/OrderDetail' description: '' '400': content: @@ -1103,7 +1095,7 @@ components: type: integer description: Total amount of BTC in the order book active_robots_today: - type: string + type: integer last_day_nonkyc_btc_premium: type: number format: double @@ -1119,6 +1111,8 @@ components: description: Total volume in BTC since exchange's inception lnd_version: type: string + cln_version: + type: string robosats_running_commit_hash: type: string alternative_site: @@ -1147,12 +1141,19 @@ components: type: number format: double description: Swap fees to perform on-chain transaction (percent) + version: + $ref: '#/components/schemas/Version' + notice_severity: + $ref: '#/components/schemas/NoticeSeverityEnum' + notice_message: + type: string required: - active_robots_today - alternative_name - alternative_site - bond_size - book_liquidity + - cln_version - current_swap_fee_rate - last_day_nonkyc_btc_premium - last_day_volume @@ -1162,10 +1163,13 @@ components: - network - node_alias - node_id + - notice_message + - notice_severity - num_public_buy_orders - num_public_sell_orders - robosats_running_commit_hash - taker_fee + - version ListOrder: type: object properties: @@ -1355,6 +1359,20 @@ components: required: - currency - id + NoticeSeverityEnum: + enum: + - none + - warning + - success + - error + - info + type: string + description: |- + * `none` - none + * `warning` - warning + * `success` - success + * `error` - error + * `info` - info NullEnum: enum: - null @@ -1952,6 +1970,19 @@ components: nullable: true required: - action + Version: + type: object + properties: + major: + type: integer + minor: + type: integer + patch: + type: integer + required: + - major + - minor + - patch securitySchemes: tokenAuth: type: apiKey diff --git a/pyproject.toml b/pyproject.toml index 5d7bf33d9..9ad50bcae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,11 @@ [tool.isort] profile = "black" + +[tool.coverage.run] +omit = [ + # omit grpc proto from coverage reports + "api/lightning/*pb2*", + # omit test and mocks from coverage reports + "tests/*", + "manage.py", + ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index aebdba920..dcd4c8f95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,9 +26,5 @@ python-gnupg==0.5.1 daphne==4.0.0 drf-spectacular==0.26.2 drf-spectacular-sidecar==2023.5.1 -black==23.3.0 -isort==5.12.0 -flake8==6.1.0 -pyflakes==3.1.0 django-cors-headers==4.3.0 base91==1.0.1 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 000000000..15af5c245 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,7 @@ +coverage==7.3.2 +black==23.3.0 +isort==5.12.0 +flake8==6.1.0 +pyflakes==3.1.0 +drf-openapi-tester==2.3.3 +pre-commit==3.5.0 \ No newline at end of file diff --git a/robosats/middleware.py b/robosats/middleware.py index 16f3109f9..7e2a5df19 100644 --- a/robosats/middleware.py +++ b/robosats/middleware.py @@ -161,6 +161,8 @@ def __call__(self, request): resized_img.save(f, format="WEBP", quality=80) user.robot.avatar = "static/assets/avatars/" + nickname + ".webp" + + update_last_login(None, user) user.save() response = self.get_response(request) diff --git a/robosats/settings.py b/robosats/settings.py index 492ac9cb0..e497364ab 100644 --- a/robosats/settings.py +++ b/robosats/settings.py @@ -275,9 +275,9 @@ MIN_PUBLIC_ORDER_DURATION = 0.166 # Bond size as percentage (%) -DEFAULT_BOND_SIZE = 3 -MIN_BOND_SIZE = 2 -MAX_BOND_SIZE = 15 +DEFAULT_BOND_SIZE = float(3) +MIN_BOND_SIZE = float(2) +MAX_BOND_SIZE = float(15) # Default time to provide a valid invoice and the trade escrow MINUTES INVOICE_AND_ESCROW_DURATION = 180 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 750a8226d..ebb419f61 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -10,6 +10,12 @@ else python manage.py collectstatic --noinput fi +# Collect static files +if [ $DEVELOPMENT ]; then + echo "Installing python development dependencies" + pip install -r requirements_dev.txt +fi + # Print first start up message when pb2/grpc files if they do exist if [ ! -f "/usr/src/robosats/api/lightning/lightning_pb2.py" ]; then echo "Looks like the first run of this container. pb2 and gRPC files were not detected on the attached volume, copying them into the attached volume /robosats/api/lightning ." diff --git a/tests/api_specs.yaml b/tests/api_specs.yaml new file mode 100644 index 000000000..3599b2d43 --- /dev/null +++ b/tests/api_specs.yaml @@ -0,0 +1,2013 @@ +openapi: 3.0.3 +info: + title: RoboSats REST API + version: 0.5.3 + x-logo: + url: https://raw.githubusercontent.com/Reckless-Satoshi/robosats/main/frontend/static/assets/images/robosats-0.1.1-banner.png + backgroundColor: '#FFFFFF' + altText: RoboSats logo + description: |2+ + + REST API Documentation for [RoboSats](https://learn.robosats.com) - A Simple and Private LN P2P Exchange + +

+ Note: + The RoboSats REST API is on v0, which in other words, is beta. + We recommend that if you don't have time to actively maintain + your project, do not build it with v0 of the API. A refactored, simpler + and more stable version - v1 will be released soonâ„¢. +

+ +paths: + /api/book/: + get: + operationId: book_list + description: Get public orders in the book. + summary: Get public orders + parameters: + - in: query + name: currency + schema: + type: integer + description: The currency id to filter by. Currency IDs can be found [here](https://github.com/RoboSats/robosats/blob/main/frontend/static/assets/currencies.json). + Value of `0` means ANY currency + - in: query + name: type + schema: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + Order type to filter by + - `0` - BUY + - `1` - SELL + - `2` - ALL + tags: + - book + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrderPublic' + description: '' + /api/chat/: + get: + operationId: chat_retrieve + description: Returns chat messages for an order with an index higher than `offset`. + tags: + - chat + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PostMessage' + description: '' + post: + operationId: chat_create + description: Adds one new message to the chatroom. + tags: + - chat + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PostMessage' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PostMessage' + multipart/form-data: + schema: + $ref: '#/components/schemas/PostMessage' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PostMessage' + description: '' + /api/historical/: + get: + operationId: historical_list + description: Get historical exchange activity. Currently, it lists each day's + total contracts and their volume in BTC since inception. + summary: Get historical exchange activity + tags: + - historical + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: + type: object + properties: + volume: + type: integer + description: Total Volume traded on that particular date + num_contracts: + type: number + description: Number of successful trades on that particular + date + examples: + TruncatedExample: + value: + - : + code: USD + price: '42069.69' + min_amount: '4.2' + max_amount: '420.69' + summary: Truncated example + description: '' + /api/info/: + get: + operationId: info_retrieve + description: |2 + + Get general info (overview) about the exchange. + + **Info**: + - Current market data + - num. of orders + - book liquidity + - 24h active robots + - 24h non-KYC premium + - 24h volume + - all time volume + - Node info + - lnd version + - node id + - node alias + - network + - Fees + - maker and taker fees + - on-chain swap fees + summary: Get info + tags: + - info + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Info' + description: '' + /api/limits/: + get: + operationId: limits_list + description: Get a list of order limits for every currency pair available. + summary: List order limits + tags: + - limits + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: + type: object + properties: + code: + type: string + description: Three letter currency symbol + price: + type: integer + min_amount: + type: integer + description: Minimum amount allowed in an order in the particular + currency + max_amount: + type: integer + description: Maximum amount allowed in an order in the particular + currency + examples: + TruncatedExample.RealResponseContainsAllTheCurrencies: + value: + - : + code: USD + price: '42069.69' + min_amount: '4.2' + max_amount: '420.69' + summary: Truncated example. Real response contains all the currencies + description: '' + /api/make/: + post: + operationId: make_create + description: |2 + + Create a new order as a maker. + + + Default values for the following fields if not specified: + - `public_duration` - **24** + - `escrow_duration` - **180** + - `bond_size` - **3.0** + - `has_range` - **false** + - `premium` - **0** + summary: Create a maker order + tags: + - make + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MakeOrder' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MakeOrder' + multipart/form-data: + schema: + $ref: '#/components/schemas/MakeOrder' + required: true + security: + - tokenAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/ListOrder' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' + '409': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' + /api/order/: + get: + operationId: order_retrieve + description: |2+ + + Get the order details. Details include/exclude attributes according to what is the status of the order + + The following fields are available irrespective of whether you are a participant or not (A participant is either a taker or a maker of an order) + All the other fields are only available when you are either the taker or the maker of the order: + + - `id` + - `status` + - `created_at` + - `expires_at` + - `type` + - `currency` + - `amount` + - `has_range` + - `min_amount` + - `max_amount` + - `payment_method` + - `is_explicit` + - `premium` + - `satoshis` + - `maker` + - `taker` + - `escrow_duration` + - `total_secs_exp` + - `penalty` + - `is_maker` + - `is_taker` + - `is_participant` + - `maker_status` + - `taker_status` + - `price_now` + + ### Order Status + + The response of this route changes according to the status of the order. Some fields are documented below (check the 'Responses' section) + with the status code of when they are available and some or not. With v1 API we aim to simplify this + route to make it easier to understand which fields are available on which order status codes. + + `status` specifies the status of the order. Below is a list of possible values (status codes) and what they mean: + - `0` "Waiting for maker bond" + - `1` "Public" + - `2` "Paused" + - `3` "Waiting for taker bond" + - `4` "Cancelled" + - `5` "Expired" + - `6` "Waiting for trade collateral and buyer invoice" + - `7` "Waiting only for seller trade collateral" + - `8` "Waiting only for buyer invoice" + - `9` "Sending fiat - In chatroom" + - `10` "Fiat sent - In chatroom" + - `11` "In dispute" + - `12` "Collaboratively cancelled" + - `13` "Sending satoshis to buyer" + - `14` "Sucessful trade" + - `15` "Failed lightning network routing" + - `16` "Wait for dispute resolution" + - `17` "Maker lost dispute" + - `18` "Taker lost dispute" + + + Notes: + - both `price_now` and `premium_now` are always calculated irrespective of whether `is_explicit` = true or false + + summary: Get order details + parameters: + - in: query + name: order_id + schema: + type: integer + required: true + tags: + - order + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderDetail' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + examples: + OrderCancelled: + value: + bad_request: This order has been cancelled collaborativelly + summary: Order cancelled + WhenTheOrderIsNotPublicAndYouNeitherTheTakerNorMaker: + value: + bad_request: This order is not available + summary: When the order is not public and you neither the taker + nor maker + WhenMakerBondExpires(asMaker): + value: + bad_request: Invoice expired. You did not confirm publishing the + order in time. Make a new order. + summary: When maker bond expires (as maker) + WhenRobosatsNodeIsDown: + value: + bad_request: The Lightning Network Daemon (LND) is down. Write + in the Telegram group to make sure the staff is aware. + summary: When Robosats node is down + description: '' + '403': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + default: This order is not available + description: '' + '404': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + default: Invalid order Id + description: '' + post: + operationId: order_create + description: |2+ + + Update an order + + `action` field is required and determines what is to be done. Below + is an explanation of what each action does: + + - `take` + - If the order has not expired and is still public, on a + successful take, you get the same response as if `GET /order` + was called and the status of the order was `3` (waiting for + taker bond) which means `bond_satoshis` and `bond_invoice` are + present in the response as well. Once the `bond_invoice` is + paid, you successfully become the taker of the order and the + status of the order changes. + - `pause` + - Toggle the status of an order from `1` to `2` and vice versa. Allowed only if status is `1` (Public) or `2` (Paused) + - `update_invoice` + - This action only is valid if you are the buyer. The `invoice` + field needs to be present in the body and the value must be a + valid LN invoice as cleartext PGP message signed with the robot key. Make sure to perform this action only when + both the bonds are locked. i.e The status of your order is + at least `6` (Waiting for trade collateral and buyer invoice) + - `update_address` + - This action is only valid if you are the buyer. This action is + used to set an on-chain payout address if you wish to have your + payout be received on-chain. Only valid if there is an address in the body as + cleartext PGP message signed with the robot key. This enables on-chain swap for the + order, so even if you earlier had submitted a LN invoice, it + will be ignored. You get to choose the `mining_fee_rate` as + well. Mining fee rate is specified in sats/vbyte. + - `cancel` + - This action is used to cancel an existing order. You cannot cancel an order if it's in one of the following states: + - `1` - Cancelled + - `5` - Expired + - `11` - In dispute + - `12` - Collaboratively cancelled + - `13` - Sending satoshis to buyer + - `14` - Successful trade + - `15` - Failed lightning network routing + - `17` - Maker lost dispute + - `18` - Taker lost dispute + + Note that there are penalties involved for cancelling a order + mid-trade so use this action carefully: + + - As a maker if you cancel an order after you have locked your + maker bond, you are returned your bond. This may change in + the future to prevent DDoSing the LN node and you won't be + returned the maker bond. + - As a taker there is a time penalty involved if you `take` an + order and cancel it without locking the taker bond. + - For both taker or maker, if you cancel the order when both + have locked their bonds (status = `6` or `7`), you loose your + bond and a percent of it goes as "rewards" to your + counterparty and some of it the platform keeps. This is to + discourage wasting time and DDoSing the platform. + - For both taker or maker, if you cancel the order when the + escrow is locked (status = `8` or `9`), you trigger a + collaborative cancel request. This sets + `(m|t)aker_asked_cancel` field to `true` depending on whether + you are the maker or the taker respectively, so that your + counterparty is informed that you asked for a cancel. + - For both taker or maker, and your counterparty asked for a + cancel (i.e `(m|t)aker_asked_cancel` is true), and you cancel + as well, a collaborative cancel takes place which returns + both the bonds and escrow to the respective parties. Note + that in the future there will be a cost for even + collaborativelly cancelling orders for both parties. + - `confirm` + - This is a **crucial** action. This confirms the sending and + receiving of fiat depending on whether you are a buyer or + seller. There is not much RoboSats can do to actually confirm + and verify the fiat payment channel. It is up to you to make + sure of the correct amount was received before you confirm. + This action is only allowed when status is either `9` (Sending + fiat - In chatroom) or `10` (Fiat sent - In chatroom) + - If you are the buyer, it simply sets `fiat_sent` to `true` + which means that you have sent the fiat using the payment + method selected by the seller and signals the seller that the + fiat payment was done. + - If you are the seller, be very careful and double check + before performing this action. Check that your fiat payment + method was successful in receiving the funds and whether it + was the correct amount. This action settles the escrow and + pays the buyer and sets the the order status to `13` (Sending + satohis to buyer) and eventually to `14` (successful trade). + - `undo_confirm` + - This action will undo the fiat_sent confirmation by the buyer + it is allowed only once the fiat is confirmed as sent and can + enable the collaborative cancellation option if an off-robosats + payment cannot be completed or is blocked. + - `dispute` + - This action is allowed only if status is `9` or `10`. It sets + the order status to `11` (In dispute) and sets `is_disputed` to + `true`. Both the bonds and the escrow are settled (i.e RoboSats + takes custody of the funds). Disputes can take long to resolve, + it might trigger force closure for unresolved HTLCs). Dispute + winner will have to submit a new invoice for value of escrow + + bond. + - `submit_statement` + - This action updates the dispute statement. Allowed only when + status is `11` (In dispute). `statement` must be sent in the + request body and should be a string. 100 chars < length of + `statement` < 5000 chars. You need to describe the reason for + raising a dispute. The `(m|t)aker_statement` field is set + respectively. Only when both parties have submitted their + dispute statement, the order status changes to `16` (Waiting + for dispute resolution) + - `rate_platform` + - Let us know how much you love (or hate 😢) RoboSats. + You can rate the platform from `1-5` using the `rate` field in the request body + + summary: Update order + parameters: + - in: query + name: order_id + schema: + type: integer + required: true + tags: + - order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateOrder' + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UpdateOrder' + multipart/form-data: + schema: + $ref: '#/components/schemas/UpdateOrder' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrderDetail' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + description: '' + /api/price/: + get: + operationId: price_list + description: Get the last market price for each currency. Also, returns some + more info about the last trade in each currency. + summary: Get last market prices + tags: + - price + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: + type: object + properties: + price: + type: integer + volume: + type: integer + premium: + type: integer + timestamp: + type: string + format: date-time + examples: + TruncatedExample.RealResponseContainsAllTheCurrencies: + value: + - : + price: 21948.89 + volume: 0.01366812 + premium: 3.5 + timestamp: '2022-09-13T14:32:40.591774Z' + summary: Truncated example. Real response contains all the currencies + description: '' + /api/reward/: + post: + operationId: reward_create + description: Withdraw user reward by submitting an invoice. The invoice must + be send as cleartext PGP message signed with the robot key + summary: Withdraw reward + tags: + - reward + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimReward' + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + WhenNoRewardsEarned: + value: + successful_withdrawal: false + bad_invoice: You have not earned rewards + summary: When no rewards earned + BadInvoiceOrInCaseOfPaymentFailure: + value: + successful_withdrawal: false + bad_invoice: Does not look like a valid lightning invoice + summary: Bad invoice or in case of payment failure + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ClaimReward' + multipart/form-data: + schema: + $ref: '#/components/schemas/ClaimReward' + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + successful_withdrawal: + type: boolean + default: true + description: '' + '400': + content: + application/json: + schema: + oneOf: + - type: object + properties: + successful_withdrawal: + type: boolean + default: false + bad_invoice: + type: string + description: More context for the reason of the failure + - type: object + properties: + successful_withdrawal: + type: boolean + default: false + bad_request: + type: string + description: More context for the reason of the failure + examples: + UserNotAuthenticated: + value: + bad_request: Woops! It seems you do not have a robot avatar + summary: User not authenticated + WhenNoRewardsEarned: + value: + successful_withdrawal: false + bad_invoice: You have not earned rewards + summary: When no rewards earned + BadInvoiceOrInCaseOfPaymentFailure: + value: + successful_withdrawal: false + bad_invoice: Does not look like a valid lightning invoice + summary: Bad invoice or in case of payment failure + description: '' + /api/robot/: + get: + operationId: robot_retrieve + description: |2+ + + Get robot info 🤖 + + An authenticated request (has the token's sha256 hash encoded as base 91 in the Authorization header) will be + returned the information about the state of a robot. + + Make sure you generate your token using cryptographically secure methods. [Here's]() the function the Javascript + client uses to generate the tokens. Since the server only receives the hash of the + token, it is responsibility of the client to create a strong token. Check + [here](https://github.com/RoboSats/robosats/blob/main/frontend/src/utils/token.js) + to see how the Javascript client creates a random strong token and how it validates entropy is optimal for tokens + created by the user at will. + + `public_key` - PGP key associated with the user (Armored ASCII format) + `encrypted_private_key` - Private PGP key. This is only stored on the backend for later fetching by + the frontend and the key can't really be used by the server since it's protected by the token + that only the client knows. Will be made an optional parameter in a future release. + On the Javascript client, It's passphrase is set to be the secret token generated. + + A gpg key can be created by: + + ```shell + gpg --full-gen-key + ``` + + it's public key can be exported in ascii armored format with: + + ```shell + gpg --export --armor + ``` + + and it's private key can be exported in ascii armored format with: + + ```shell + gpg --export-secret-keys --armor + ``` + + summary: Get robot info + tags: + - robot + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + encrypted_private_key: + type: string + description: Armored ASCII PGP private key block + nickname: + type: string + description: Username generated (Robot name) + public_key: + type: string + description: Armored ASCII PGP public key block + wants_stealth: + type: boolean + default: false + description: Whether the user prefers stealth invoices + found: + type: boolean + description: Robot had been created in the past. Only if the robot + was created +5 mins ago. + tg_enabled: + type: boolean + description: The robot has telegram notifications enabled + tg_token: + type: string + description: Token to enable telegram with /start + tg_bot_name: + type: string + description: Name of the coordinator's telegram bot + active_order_id: + type: integer + description: Active order id if present + last_order_id: + type: integer + description: Last order id if present + earned_rewards: + type: integer + description: Satoshis available to be claimed + last_login: + type: string + format: date-time + nullable: true + description: Last time the coordinator saw this robot + examples: + SuccessfullyRetrievedRobot: + value: + nickname: SatoshiNakamoto21 + public_key: |- + -----BEGIN PGP PUBLIC KEY BLOCK----- + + ...... + ...... + encrypted_private_key: |- + -----BEGIN PGP PRIVATE KEY BLOCK----- + + ...... + ...... + wants_stealth: true + summary: Successfully retrieved robot + description: '' + /api/stealth/: + post: + operationId: stealth_create + description: Update stealth invoice option for the user + summary: Update stealth option + tags: + - stealth + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Stealth' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Stealth' + multipart/form-data: + schema: + $ref: '#/components/schemas/Stealth' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Stealth' + description: '' + '400': + content: + application/json: + schema: + type: object + properties: + bad_request: + type: string + description: Reason for the failure + description: '' + /api/ticks/: + get: + operationId: ticks_list + description: |- + Get all market ticks. Returns a list of all the market ticks since inception. + CEX price is also recorded for useful insight on the historical premium of Non-KYC BTC. Price is set when taker bond is locked. + summary: Get market ticks + parameters: + - in: query + name: end + schema: + type: string + description: End date formatted as DD-MM-YYYY + - in: query + name: start + schema: + type: string + description: Start date formatted as DD-MM-YYYY + tags: + - ticks + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tick' + description: '' +components: + schemas: + ActionEnum: + enum: + - pause + - take + - update_invoice + - update_address + - submit_statement + - dispute + - cancel + - confirm + - undo_confirm + - rate_platform + type: string + description: |- + * `pause` - pause + * `take` - take + * `update_invoice` - update_invoice + * `update_address` - update_address + * `submit_statement` - submit_statement + * `dispute` - dispute + * `cancel` - cancel + * `confirm` - confirm + * `undo_confirm` - undo_confirm + * `rate_platform` - rate_platform + BlankEnum: + enum: + - '' + ClaimReward: + type: object + properties: + invoice: + type: string + nullable: true + description: A valid LN invoice with the reward amount to withdraw + maxLength: 2000 + CurrencyEnum: + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 26 + - 27 + - 28 + - 29 + - 30 + - 31 + - 32 + - 33 + - 34 + - 35 + - 36 + - 37 + - 38 + - 39 + - 40 + - 41 + - 42 + - 43 + - 44 + - 45 + - 46 + - 47 + - 48 + - 49 + - 50 + - 51 + - 52 + - 53 + - 54 + - 55 + - 56 + - 57 + - 58 + - 59 + - 60 + - 61 + - 62 + - 63 + - 64 + - 65 + - 66 + - 67 + - 68 + - 69 + - 70 + - 71 + - 72 + - 73 + - 74 + - 75 + - 300 + - 1000 + type: integer + description: |- + * `1` - USD + * `2` - EUR + * `3` - JPY + * `4` - GBP + * `5` - AUD + * `6` - CAD + * `7` - CHF + * `8` - CNY + * `9` - HKD + * `10` - NZD + * `11` - SEK + * `12` - KRW + * `13` - SGD + * `14` - NOK + * `15` - MXN + * `16` - BYN + * `17` - RUB + * `18` - ZAR + * `19` - TRY + * `20` - BRL + * `21` - CLP + * `22` - CZK + * `23` - DKK + * `24` - HRK + * `25` - HUF + * `26` - INR + * `27` - ISK + * `28` - PLN + * `29` - RON + * `30` - ARS + * `31` - VES + * `32` - COP + * `33` - PEN + * `34` - UYU + * `35` - PYG + * `36` - BOB + * `37` - IDR + * `38` - ANG + * `39` - CRC + * `40` - CUP + * `41` - DOP + * `42` - GHS + * `43` - GTQ + * `44` - ILS + * `45` - JMD + * `46` - KES + * `47` - KZT + * `48` - MYR + * `49` - NAD + * `50` - NGN + * `51` - AZN + * `52` - PAB + * `53` - PHP + * `54` - PKR + * `55` - QAR + * `56` - SAR + * `57` - THB + * `58` - TTD + * `59` - VND + * `60` - XOF + * `61` - TWD + * `62` - TZS + * `63` - XAF + * `64` - UAH + * `65` - EGP + * `66` - LKR + * `67` - MAD + * `68` - AED + * `69` - TND + * `70` - ETB + * `71` - GEL + * `72` - UGX + * `73` - RSD + * `74` - IRT + * `75` - BDT + * `300` - XAU + * `1000` - BTC + ExpiryReasonEnum: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + description: |- + * `0` - Expired not taken + * `1` - Maker bond not locked + * `2` - Escrow not locked + * `3` - Invoice not submitted + * `4` - Neither escrow locked or invoice submitted + Info: + type: object + properties: + num_public_buy_orders: + type: integer + num_public_sell_orders: + type: integer + book_liquidity: + type: integer + description: Total amount of BTC in the order book + active_robots_today: + type: integer + last_day_nonkyc_btc_premium: + type: number + format: double + description: Average premium (weighted by volume) of the orders in the last + 24h + last_day_volume: + type: number + format: double + description: Total volume in BTC in the last 24h + lifetime_volume: + type: number + format: double + description: Total volume in BTC since exchange's inception + lnd_version: + type: string + cln_version: + type: string + robosats_running_commit_hash: + type: string + alternative_site: + type: string + alternative_name: + type: string + node_alias: + type: string + node_id: + type: string + network: + type: string + maker_fee: + type: number + format: double + description: Exchange's set maker fee + taker_fee: + type: number + format: double + description: 'Exchange''s set taker fee ' + bond_size: + type: number + format: double + description: Default bond size (percent) + current_swap_fee_rate: + type: number + format: double + description: Swap fees to perform on-chain transaction (percent) + version: + $ref: '#/components/schemas/Version' + notice_severity: + $ref: '#/components/schemas/NoticeSeverityEnum' + notice_message: + type: string + required: + - active_robots_today + - alternative_name + - alternative_site + - bond_size + - book_liquidity + - cln_version + - current_swap_fee_rate + - last_day_nonkyc_btc_premium + - last_day_volume + - lifetime_volume + - lnd_version + - maker_fee + - network + - node_alias + - node_id + - notice_message + - notice_severity + - num_public_buy_orders + - num_public_sell_orders + - robosats_running_commit_hash + - taker_fee + - version + ListOrder: + type: object + properties: + id: + type: integer + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/StatusEnum' + minimum: 0 + maximum: 32767 + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + nullable: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + maxLength: 70 + is_explicit: + type: boolean + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + maker: + type: integer + nullable: true + taker: + type: integer + nullable: true + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + latitude: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,6})?$ + nullable: true + longitude: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ + nullable: true + required: + - expires_at + - id + - type + MakeOrder: + type: object + properties: + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + description: Currency id. See [here](https://github.com/RoboSats/robosats/blob/main/frontend/static/assets/currencies.json) + for a list of all IDs + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + default: false + description: |- + Whether the order specifies a range of amount or a fixed amount. + + If `true`, then `min_amount` and `max_amount` fields are **required**. + + If `false` then `amount` is **required** + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + default: not specified + description: Can be any string. The UI recognizes [these payment methods](https://github.com/RoboSats/robosats/blob/main/frontend/src/components/payment-methods/Methods.js) + and displays them with a logo. + maxLength: 70 + is_explicit: + type: boolean + default: false + description: Whether the order is explicitly priced or not. If set to `true` + then `satoshis` need to be specified + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + public_duration: + type: integer + maximum: 86400 + minimum: 597.6 + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + latitude: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,6})?$ + nullable: true + longitude: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ + nullable: true + required: + - currency + - type + Nested: + type: object + properties: + id: + type: integer + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyEnum' + minimum: 0 + maximum: 32767 + exchange_rate: + type: string + format: decimal + pattern: ^-?\d{0,14}(?:\.\d{0,4})?$ + nullable: true + timestamp: + type: string + format: date-time + required: + - currency + - id + NoticeSeverityEnum: + enum: + - none + - warning + - success + - error + - info + type: string + description: |- + * `none` - none + * `warning` - warning + * `success` - success + * `error` - error + * `info` - info + NullEnum: + enum: + - null + OrderDetail: + type: object + properties: + id: + type: integer + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/StatusEnum' + minimum: 0 + maximum: 32767 + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + nullable: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + maxLength: 70 + is_explicit: + type: boolean + premium: + type: string + description: Premium over the CEX price set by the maker + premium_now: + type: number + format: double + description: Premium over the CEX price at the current time + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + satoshis_now: + type: integer + description: Maximum size of the order right now in Satoshis + maker: + type: integer + nullable: true + taker: + type: integer + nullable: true + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + total_secs_exp: + type: integer + description: Duration of time (in seconds) to expire, according to the current + status of order.This is duration of time after `created_at` (in seconds) + that the order will automatically expire.This value changes according + to which stage the order is in + penalty: + type: string + format: date-time + description: Time when the user penalty will expire. Penalty applies when + you create orders repeatedly without commiting a bond + is_maker: + type: boolean + description: Whether you are the maker or not + is_taker: + type: boolean + description: Whether you are the taker or not + is_participant: + type: boolean + description: True if you are either a taker or maker, False otherwise + maker_status: + type: string + description: |- + Status of the maker: + - **'Active'** (seen within last 2 min) + - **'Seen Recently'** (seen within last 10 min) + - **'Inactive'** (seen more than 10 min ago) + + Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty + taker_status: + type: string + description: |- + Status of the maker: + - **'Active'** (seen within last 2 min) + - **'Seen Recently'** (seen within last 10 min) + - **'Inactive'** (seen more than 10 min ago) + + Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty + price_now: + type: number + format: double + description: Price of the order in the order's currency at the time of request + (upto 5 significant digits) + premium_percentile: + type: number + format: double + description: (Only if `is_maker`) Premium percentile of your order compared + to other public orders in the same currency currently in the order book + num_similar_orders: + type: integer + description: (Only if `is_maker`) The number of public orders of the same + currency currently in the order book + tg_enabled: + type: boolean + description: (Only if `is_maker`) Whether Telegram notification is enabled + or not + tg_token: + type: string + description: (Only if `is_maker`) Your telegram bot token required to enable + notifications. + tg_bot_name: + type: string + description: (Only if `is_maker`) The Telegram username of the bot + is_buyer: + type: boolean + description: Whether you are a buyer of sats (you will be receiving sats) + is_seller: + type: boolean + description: Whether you are a seller of sats or not (you will be sending + sats) + maker_nick: + type: string + description: Nickname (Robot name) of the maker + taker_nick: + type: string + description: Nickname (Robot name) of the taker + status_message: + type: string + description: The current status of the order corresponding to the `status` + is_fiat_sent: + type: boolean + description: Whether or not the fiat amount is sent by the buyer + is_disputed: + type: boolean + description: Whether or not the counterparty raised a dispute + ur_nick: + type: string + description: Your Nick + maker_locked: + type: boolean + description: True if maker bond is locked, False otherwise + taker_locked: + type: boolean + description: True if taker bond is locked, False otherwise + escrow_locked: + type: boolean + description: True if escrow is locked, False otherwise. Escrow is the sats + to be sold, held by Robosats until the trade is finised. + trade_satoshis: + type: integer + description: 'Seller sees the amount of sats they need to send. Buyer sees + the amount of sats they will receive ' + bond_invoice: + type: string + description: When `status` = `0`, `3`. Bond invoice to be paid + bond_satoshis: + type: integer + description: The bond amount in satoshis + escrow_invoice: + type: string + description: For the seller, the escrow invoice to be held by RoboSats + escrow_satoshis: + type: integer + description: The escrow amount in satoshis + invoice_amount: + type: integer + description: The amount in sats the buyer needs to submit an invoice of + to receive the trade amount + swap_allowed: + type: boolean + description: Whether on-chain swap is allowed + swap_failure_reason: + type: string + description: Reason for why on-chain swap is not available + suggested_mining_fee_rate: + type: integer + description: fee in sats/vbyte for the on-chain swap + swap_fee_rate: + type: number + format: double + description: in percentage, the swap fee rate the platform charges + pending_cancel: + type: boolean + description: Your counterparty requested for a collaborative cancel when + `status` is either `8`, `9` or `10` + asked_for_cancel: + type: boolean + description: You requested for a collaborative cancel `status` is either + `8`, `9` or `10` + statement_submitted: + type: boolean + description: True if you have submitted a statement. Available when `status` + is `11` + retries: + type: integer + description: Number of times ln node has tried to make the payment to you + (only if you are the buyer) + next_retry_time: + type: string + format: date-time + description: The next time payment will be retried. Payment is retried every + 1 sec + failure_reason: + type: string + description: The reason the payout failed + invoice_expired: + type: boolean + description: True if the payout invoice expired. `invoice_amount` will be + re-set and sent which means the user has to submit a new invoice to be + payed + public_duration: + type: integer + maximum: 86400 + minimum: 597.6 + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + trade_fee_percent: + type: integer + description: The fee for the trade (fees differ for maker and taker) + bond_size_sats: + type: integer + description: The size of the bond in sats + bond_size_percent: + type: integer + description: same as `bond_size` + maker_summary: + $ref: '#/components/schemas/Summary' + taker_summary: + $ref: '#/components/schemas/Summary' + platform_summary: + $ref: '#/components/schemas/PlatformSummary' + expiry_reason: + nullable: true + minimum: 0 + maximum: 32767 + oneOf: + - $ref: '#/components/schemas/ExpiryReasonEnum' + - $ref: '#/components/schemas/NullEnum' + expiry_message: + type: string + description: The reason the order expired (message associated with the `expiry_reason`) + num_satoshis: + type: integer + description: only if status = `14` (Successful Trade) and is_buyer = `true` + sent_satoshis: + type: integer + description: only if status = `14` (Successful Trade) and is_buyer = `true` + txid: + type: string + description: Transaction id of the on-chain swap payout. Only if status + = `14` (Successful Trade) and is_buyer = `true` + network: + type: string + description: The network eg. 'testnet', 'mainnet'. Only if status = `14` + (Successful Trade) and is_buyer = `true` + latitude: + type: number + format: double + description: Latitude of the order for F2F payments + longitude: + type: number + format: double + description: Longitude of the order for F2F payments + required: + - expires_at + - id + - type + OrderPublic: + type: object + properties: + id: + type: integer + readOnly: true + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + minimum: 0 + maximum: 32767 + currency: + type: integer + nullable: true + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + has_range: + type: boolean + min_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + max_amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + payment_method: + type: string + maxLength: 70 + is_explicit: + type: boolean + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + satoshis: + type: integer + maximum: 5000000 + minimum: 20000 + nullable: true + maker: + type: integer + nullable: true + maker_nick: + type: string + maker_status: + type: string + description: Status of the nick - "Active" or "Inactive" + price: + type: number + format: double + description: Price in order's fiat currency + escrow_duration: + type: integer + maximum: 28800 + minimum: 1800 + satoshis_now: + type: integer + description: The amount of sats to be traded at the present moment (not + including the fees) + bond_size: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,2})?$ + latitude: + type: string + format: decimal + pattern: ^-?\d{0,2}(?:\.\d{0,6})?$ + nullable: true + longitude: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,6})?$ + nullable: true + required: + - expires_at + - id + - type + PlatformSummary: + type: object + properties: + contract_timestamp: + type: string + format: date-time + description: Timestamp of when the contract was finalized (price and sats + fixed) + contract_total_time: + type: number + format: double + description: The time taken for the contract to complete (from taker taking + the order to completion of order) in seconds + routing_fee_sats: + type: integer + description: Sats payed by the exchange for routing fees. Mining fee in + case of on-chain swap payout + trade_revenue_sats: + type: integer + description: The sats the exchange earned from the trade + PostMessage: + type: object + properties: + PGP_message: + type: string + nullable: true + maxLength: 5000 + order_id: + type: integer + minimum: 0 + description: Order ID of chatroom + offset: + type: integer + minimum: 0 + nullable: true + description: Offset for message index to get as response + required: + - order_id + RatingEnum: + enum: + - '1' + - '2' + - '3' + - '4' + - '5' + type: string + description: |- + * `1` - 1 + * `2` - 2 + * `3` - 3 + * `4` - 4 + * `5` - 5 + StatusEnum: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + type: integer + description: |- + * `0` - Waiting for maker bond + * `1` - Public + * `2` - Paused + * `3` - Waiting for taker bond + * `4` - Cancelled + * `5` - Expired + * `6` - Waiting for trade collateral and buyer invoice + * `7` - Waiting only for seller trade collateral + * `8` - Waiting only for buyer invoice + * `9` - Sending fiat - In chatroom + * `10` - Fiat sent - In chatroom + * `11` - In dispute + * `12` - Collaboratively cancelled + * `13` - Sending satoshis to buyer + * `14` - Sucessful trade + * `15` - Failed lightning network routing + * `16` - Wait for dispute resolution + * `17` - Maker lost dispute + * `18` - Taker lost dispute + Stealth: + type: object + properties: + wantsStealth: + type: boolean + required: + - wantsStealth + Summary: + type: object + properties: + sent_fiat: + type: integer + description: same as `amount` (only for buyer) + received_sats: + type: integer + description: same as `trade_satoshis` (only for buyer) + is_swap: + type: boolean + description: True if the payout was on-chain (only for buyer) + received_onchain_sats: + type: integer + description: The on-chain sats received (only for buyer and if `is_swap` + is `true`) + mining_fee_sats: + type: integer + description: Mining fees paid in satoshis (only for buyer and if `is_swap` + is `true`) + swap_fee_sats: + type: integer + description: Exchange swap fee in sats (i.e excluding miner fees) (only + for buyer and if `is_swap` is `true`) + swap_fee_percent: + type: number + format: double + description: same as `swap_fee_rate` (only for buyer and if `is_swap` is + `true` + sent_sats: + type: integer + description: The total sats you sent (only for seller) + received_fiat: + type: integer + description: same as `amount` (only for seller) + trade_fee_sats: + type: integer + description: Exchange fees in sats (Does not include swap fee and miner + fee) + Tick: + type: object + properties: + timestamp: + type: string + format: date-time + currency: + allOf: + - $ref: '#/components/schemas/Nested' + readOnly: true + volume: + type: string + format: decimal + nullable: true + price: + type: string + format: decimal + pattern: ^-?\d{0,14}(?:\.\d{0,2})?$ + nullable: true + premium: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + nullable: true + fee: + type: string + format: decimal + required: + - currency + TypeEnum: + enum: + - 0 + - 1 + type: integer + description: |- + * `0` - BUY + * `1` - SELL + UpdateOrder: + type: object + properties: + invoice: + type: string + nullable: true + description: |+ + Invoice used for payouts. Must be PGP signed with the robot's public key. The expected Armored PGP header is -----BEGIN PGP SIGNED MESSAGE----- + Hash: SHA512 + + maxLength: 15000 + routing_budget_ppm: + type: integer + maximum: 100001 + minimum: 0 + nullable: true + default: 0 + description: Max budget to allocate for routing in PPM + address: + type: string + nullable: true + description: |+ + Onchain address used for payouts. Must be PGP signed with the robot's public key. The expected Armored PGP header is -----BEGIN PGP SIGNED MESSAGE----- + Hash: SHA512 + + maxLength: 15000 + statement: + type: string + nullable: true + maxLength: 500000 + action: + $ref: '#/components/schemas/ActionEnum' + rating: + nullable: true + oneOf: + - $ref: '#/components/schemas/RatingEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + mining_fee_rate: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,3})?$ + nullable: true + required: + - action + Version: + type: object + properties: + major: + type: integer + minor: + type: integer + patch: + type: integer + required: + - major + - minor + - patch + securitySchemes: + tokenAuth: + type: apiKey + in: header + name: Authorization + description: Token-based authentication with required prefix "Token" diff --git a/tests/compose.env b/tests/compose.env new file mode 100644 index 000000000..8fffd7565 --- /dev/null +++ b/tests/compose.env @@ -0,0 +1,7 @@ +ROBOSATS_ENVS_FILE=".env-sample" + +BITCOIND_VERSION='24.0.1' +LND_VERSION='v0.17.0-beta' +CLN_VERSION='v23.08.1' +REDIS_VERSION='7.2.1' +POSTGRES_VERSION='14.2' \ No newline at end of file diff --git a/tests/node_utils.py b/tests/node_utils.py new file mode 100644 index 000000000..5987a069a --- /dev/null +++ b/tests/node_utils.py @@ -0,0 +1,197 @@ +import codecs +import sys +import time + +import requests +from requests.auth import HTTPBasicAuth +from requests.exceptions import ReadTimeout + +wait_step = 0.2 + + +def get_node(name="robot"): + """ + We have two regtest LND nodes: "coordinator" (the robosats backend) and "robot" (the robosats user) + """ + if name == "robot": + macaroon = codecs.encode( + open("/lndrobot/data/chain/bitcoin/regtest/admin.macaroon", "rb").read(), + "hex", + ) + port = 8080 + + elif name == "coordinator": + macaroon = codecs.encode( + open("/lnd/data/chain/bitcoin/regtest/admin.macaroon", "rb").read(), "hex" + ) + port = 8081 + + return {"port": port, "headers": {"Grpc-Metadata-macaroon": macaroon}} + + +def get_lnd_node_id(node_name): + node = get_node(node_name) + response = requests.get( + f'http://localhost:{node["port"]}/v1/getinfo', headers=node["headers"] + ) + data = response.json() + return data["identity_pubkey"] + + +def get_cln_node_id(): + from api.lightning.cln import CLNNode + + response = CLNNode.get_info() + return response.id.hex() + + +def wait_for_lnd_node_sync(node_name): + node = get_node(node_name) + waited = 0 + while True: + response = requests.get( + f'http://localhost:{node["port"]}/v1/getinfo', headers=node["headers"] + ) + if response.json()["synced_to_chain"]: + return + else: + sys.stdout.write( + f"\rWaiting for {node_name} node chain sync {round(waited,1)}s" + ) + sys.stdout.flush() + waited += wait_step + time.sleep(wait_step) + + +def wait_for_lnd_active_channels(node_name): + node = get_node(node_name) + waited = 0 + while True: + response = requests.get( + f'http://localhost:{node["port"]}/v1/getinfo', headers=node["headers"] + ) + if response.json()["num_active_channels"] > 0: + return + else: + sys.stdout.write( + f"\rWaiting for {node_name} node channels to be active {round(waited,1)}s" + ) + sys.stdout.flush() + waited += wait_step + time.sleep(wait_step) + + +def wait_for_cln_node_sync(): + from api.lightning.cln import CLNNode + + waited = 0 + while True: + response = CLNNode.get_info() + if response.warning_bitcoind_sync or response.warning_lightningd_sync: + sys.stdout.write( + f"\rWaiting for coordinator CLN node sync {round(waited,1)}s" + ) + sys.stdout.flush() + waited += wait_step + time.sleep(wait_step) + else: + return + + +def wait_for_cln_active_channels(): + from api.lightning.cln import CLNNode + + waited = 0 + while True: + response = CLNNode.get_info() + if response.num_active_channels > 0: + return + else: + sys.stdout.write( + f"\rWaiting for coordinator CLN node channels to be active {round(waited,1)}s" + ) + sys.stdout.flush() + waited += wait_step + time.sleep(wait_step) + + +def connect_to_node(node_name, node_id, ip_port): + node = get_node(node_name) + data = {"addr": {"pubkey": node_id, "host": ip_port}} + while True: + response = requests.post( + f'http://localhost:{node["port"]}/v1/peers', + json=data, + headers=node["headers"], + ) + if response.json() == {}: + print("Peered robot node to coordinator node!") + return response.json() + else: + if "already connected to peer" in response.json()["message"]: + return response.json() + print(f"Could not peer coordinator node: {response.json()}") + time.sleep(wait_step) + + +def open_channel(node_name, node_id, local_funding_amount, push_sat): + node = get_node(node_name) + data = { + "node_pubkey_string": node_id, + "local_funding_amount": local_funding_amount, + "push_sat": push_sat, + } + response = requests.post( + f'http://localhost:{node["port"]}/v1/channels', + json=data, + headers=node["headers"], + ) + return response.json() + + +def create_address(node_name): + node = get_node(node_name) + response = requests.get( + f'http://localhost:{node["port"]}/v1/newaddress', headers=node["headers"] + ) + return response.json()["address"] + + +def generate_blocks(address, num_blocks): + print(f"Mining {num_blocks} blocks") + data = { + "jsonrpc": "1.0", + "id": "curltest", + "method": "generatetoaddress", + "params": [num_blocks, address], + } + response = requests.post( + "http://localhost:18443", json=data, auth=HTTPBasicAuth("test", "test") + ) + return response.json() + + +def pay_invoice(node_name, invoice): + node = get_node(node_name) + data = {"payment_request": invoice} + try: + requests.post( + f'http://localhost:{node["port"]}/v1/channels/transactions', + json=data, + headers=node["headers"], + timeout=1, + ) + except ReadTimeout: + # Request to pay hodl invoice has timed out: that's good! + return + + +def add_invoice(node_name, amount): + node = get_node(node_name) + data = {"value": amount} + response = requests.post( + f'http://localhost:{node["port"]}/v1/invoices', + json=data, + headers=node["headers"], + ) + return response.json()["payment_request"] diff --git a/tests/robots/2/token b/tests/robots/2/token index e69de29bb..c2da02967 100644 --- a/tests/robots/2/token +++ b/tests/robots/2/token @@ -0,0 +1 @@ +oKrH73YD4ISQ0wzLNyPBeGp2OK7JTKghDfJe \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 000000000..7e30e835c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,28 @@ +import urllib.request + +from openapi_tester.schema_tester import SchemaTester +from rest_framework.response import Response +from rest_framework.test import APITestCase + +# Update api specs to the newest from a running django server (if any) +try: + urllib.request.urlretrieve( + "http://127.0.0.1:8000/api/schema", "docs/assets/schemas/api-latest.yaml" + ) +except Exception as e: + print(f"Could not fetch latests API specs: {e}") + print("Using previously existing api-latest.yaml definitions from docs") + +schema_tester = SchemaTester(schema_file_path="docs/assets/schemas/api-latest.yaml") + + +class BaseAPITestCase(APITestCase): + @staticmethod + def assertResponse(response: Response, **kwargs) -> None: + """helper to run validate_response and pass kwargs to it""" + + # List of endpoints with no available OpenAPI schema + skip_paths = ["/coordinator/login/"] + + if response.request["PATH_INFO"] not in skip_paths: + schema_tester.validate_response(response=response, **kwargs) diff --git a/tests/test_coordinator_info.py b/tests/test_coordinator_info.py new file mode 100644 index 000000000..4b7c37866 --- /dev/null +++ b/tests/test_coordinator_info.py @@ -0,0 +1,62 @@ +import json + +from decouple import config +from django.conf import settings +from django.contrib.auth.models import User +from django.test import Client +from django.urls import reverse + +from tests.test_api import BaseAPITestCase + +FEE = config("FEE", cast=float, default=0.2) +NODE_ID = config("NODE_ID", cast=str, default="033b58d7......") +MAKER_FEE = FEE * config("FEE_SPLIT", cast=float, default=0.125) +TAKER_FEE = FEE * (1 - config("FEE_SPLIT", cast=float, default=0.125)) +BOND_SIZE = config("BOND_SIZE", cast=float, default=3) +NOTICE_SEVERITY = config("NOTICE_SEVERITY", cast=str, default="none") +NOTICE_MESSAGE = config("NOTICE_MESSAGE", cast=str, default="") + + +class CoordinatorInfoTest(BaseAPITestCase): + su_pass = "12345678" + su_name = config("ESCROW_USERNAME", cast=str, default="admin") + + def setUp(self): + """ + Create a superuser. The superuser is the escrow party. + """ + self.client = Client() + User.objects.create_superuser(self.su_name, "super@user.com", self.su_pass) + + def test_info(self): + path = reverse("info") + + response = self.client.get(path) + data = json.loads(response.content.decode()) + + self.assertEqual(response.status_code, 200) + self.assertResponse(response) + + self.assertEqual(data["num_public_buy_orders"], 0) + self.assertEqual(data["num_public_sell_orders"], 0) + self.assertEqual(data["book_liquidity"], 0) + self.assertEqual(data["active_robots_today"], 0) + self.assertEqual(data["last_day_nonkyc_btc_premium"], 0) + self.assertEqual(data["last_day_volume"], 0) + self.assertEqual(data["lifetime_volume"], 0) + self.assertTrue(isinstance(data["lnd_version"], str)) + self.assertTrue(isinstance(data["cln_version"], str)) + self.assertEqual( + data["robosats_running_commit_hash"], "00000000000000000000 dev" + ) + self.assertEqual(data["version"], settings.VERSION) + self.assertEqual(data["node_id"], NODE_ID) + self.assertEqual( + data["network"], "testnet" + ) # tests take place in regtest, but this attribute is read from .env + self.assertAlmostEqual(data["maker_fee"], MAKER_FEE) + self.assertAlmostEqual(data["taker_fee"], TAKER_FEE) + self.assertAlmostEqual(data["bond_size"], BOND_SIZE) + self.assertEqual(data["notice_severity"], NOTICE_SEVERITY) + self.assertEqual(data["notice_message"], NOTICE_MESSAGE) + self.assertEqual(data["current_swap_fee_rate"], 0) diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index 755534210..9e87cc1cb 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -1,67 +1,180 @@ import json +import time from datetime import datetime from decimal import Decimal from decouple import config from django.contrib.auth.models import User -from django.test import Client, TestCase +from django.urls import reverse +from api.management.commands.follow_invoices import Command as FollowInvoices from api.models import Currency, Order from api.tasks import cache_market +from tests.node_utils import ( + connect_to_node, + create_address, + generate_blocks, + get_cln_node_id, + get_lnd_node_id, + open_channel, + pay_invoice, + wait_for_cln_active_channels, + wait_for_cln_node_sync, + wait_for_lnd_active_channels, + wait_for_lnd_node_sync, +) +from tests.test_api import BaseAPITestCase +LNVENDOR = config("LNVENDOR", cast=str, default="LND") -class TradeTest(TestCase): + +def read_file(file_path): + """ + Read a file and return its content. + """ + with open(file_path, "r") as file: + return file.read() + + +class TradeTest(BaseAPITestCase): su_pass = "12345678" su_name = config("ESCROW_USERNAME", cast=str, default="admin") - def setUp(self): + maker_form_with_range = { + "type": Order.Types.BUY, + "currency": 1, + "has_range": True, + "min_amount": 21, + "max_amount": 101.7, + "payment_method": "Advcash Cash F2F", + "is_explicit": False, + "premium": 3.34, + "public_duration": 69360, + "escrow_duration": 8700, + "bond_size": 3.5, + "latitude": 34.7455, + "longitude": 135.503, + } + + def wait_nodes_sync(): + wait_for_lnd_node_sync("robot") + if LNVENDOR == "LND": + wait_for_lnd_node_sync("coordinator") + elif LNVENDOR == "CLN": + wait_for_cln_node_sync() + + def wait_active_channels(): + wait_for_lnd_active_channels("robot") + if LNVENDOR == "LND": + wait_for_lnd_active_channels("coordinator") + elif LNVENDOR == "CLN": + wait_for_cln_active_channels() + + @classmethod + def setUpTestData(cls): """ - Create a superuser. The superuser is the escrow party. + Set up initial data for the test case. """ - self.client = Client() - User.objects.create_superuser(self.su_name, "super@user.com", self.su_pass) + # Create super user + User.objects.create_superuser(cls.su_name, "super@user.com", cls.su_pass) + + # Fetch currency prices from external APIs + cache_market() + + # Fund two LN nodes in regtest and open channels + # Coordinator is either LND or CLN. Robot user is always LND. + if LNVENDOR == "LND": + coordinator_node_id = get_lnd_node_id("coordinator") + coordinator_port = 9735 + elif LNVENDOR == "CLN": + coordinator_node_id = get_cln_node_id() + coordinator_port = 9737 + + print("Coordinator Node ID: ", coordinator_node_id) + + funding_address = create_address("robot") + generate_blocks(funding_address, 101) + cls.wait_nodes_sync() + + # Open channel between Robot user and coordinator + print(f"\nOpening channel from Robot user node to coordinator {LNVENDOR} node") + connect_to_node("robot", coordinator_node_id, f"localhost:{coordinator_port}") + open_channel("robot", coordinator_node_id, 100_000_000, 50_000_000) + + # Generate 10 blocks so the channel becomes active and wait for sync + generate_blocks(funding_address, 10) + + # Wait a tiny bit so payments can be done in the new channel + cls.wait_nodes_sync() + cls.wait_active_channels() + time.sleep(1) def test_login_superuser(self): - path = "/coordinator/login/" + """ + Test the login functionality for the superuser. + """ + path = reverse("admin:login") data = {"username": self.su_name, "password": self.su_pass} response = self.client.post(path, data) self.assertEqual(response.status_code, 302) + self.assertResponse( + response + ) # should skip given that /coordinator/login is not documented - def get_robot_auth(self, robot_index): + def test_cache_market(self): """ - Crates an AUTH header that embeds token, pub_key and enc_priv_key into a single string - just as requested by the robosats token middleware. + Test if the cache_market() call during test setup worked + """ + usd = Currency.objects.get(id=1) + self.assertIsInstance( + usd.exchange_rate, + Decimal, + f"Exchange rate is not a Decimal. Got {type(usd.exchange_rate)}", + ) + self.assertGreater( + usd.exchange_rate, 0, "Exchange rate is not higher than zero" + ) + self.assertIsInstance( + usd.timestamp, datetime, "External price timestamp is not a datetime" + ) + + def get_robot_auth(self, robot_index, first_encounter=False): + """ + Create an AUTH header that embeds token, pub_key, and enc_priv_key into a single string + as requested by the robosats token middleware. """ - with open(f"tests/robots/{robot_index}/b91_token", "r") as file: - b91_token = file.read() - with open(f"tests/robots/{robot_index}/pub_key", "r") as file: - pub_key = file.read() - with open(f"tests/robots/{robot_index}/enc_priv_key", "r") as file: - enc_priv_key = file.read() - headers = { - "HTTP_AUTHORIZATION": f"Token {b91_token} | Public {pub_key} | Private {enc_priv_key}" - } - return headers, pub_key, enc_priv_key + b91_token = read_file(f"tests/robots/{robot_index}/b91_token") + pub_key = read_file(f"tests/robots/{robot_index}/pub_key") + enc_priv_key = read_file(f"tests/robots/{robot_index}/enc_priv_key") - def create_robot(self, robot_index): + # First time a robot authenticated, it is registered by the backend, so pub_key and enc_priv_key is needed + if first_encounter: + headers = { + "HTTP_AUTHORIZATION": f"Token {b91_token} | Public {pub_key} | Private {enc_priv_key}" + } + else: + headers = {"HTTP_AUTHORIZATION": f"Token {b91_token}"} + + return headers + + def assert_robot(self, response, robot_index): """ - Creates the robots in /tests/robots/{robot_index} + Assert that the robot is created correctly. """ - path = "/api/robot/" - headers, pub_key, enc_priv_key = self.get_robot_auth(robot_index) + nickname = read_file(f"tests/robots/{robot_index}/nickname") + pub_key = read_file(f"tests/robots/{robot_index}/pub_key") + enc_priv_key = read_file(f"tests/robots/{robot_index}/enc_priv_key") - response = self.client.get(path, **headers) data = json.loads(response.content.decode()) - with open(f"tests/robots/{robot_index}/nickname", "r") as file: - expected_nickname = file.read() - self.assertEqual(response.status_code, 200) + self.assertResponse(response) + self.assertEqual( data["nickname"], - expected_nickname, - f"Robot {robot_index} created nickname is not MyopicRacket333", + nickname, + f"Robot created nickname is not {nickname}", ) self.assertEqual( data["public_key"], pub_key, "Returned public Kky does not match" @@ -86,79 +199,59 @@ def create_robot(self, robot_index): ) self.assertEqual(data["earned_rewards"], 0, "The new robot's rewards are not 0") - def test_create_robots(self): + def create_robot(self, robot_index): """ - Creates two robots to be used in the trade tests + Creates the robots in /tests/robots/{robot_index} """ - self.create_robot(robot_index=1) - self.create_robot(robot_index=2) + path = reverse("robot") + headers = self.get_robot_auth(robot_index, True) - def test_cache_market(self): - cache_market() + return self.client.get(path, **headers) - usd = Currency.objects.get(id=1) - self.assertTrue( - isinstance(usd.exchange_rate, Decimal), "Exchange rate is not decimal" - ) - self.assertLess(0, usd.exchange_rate, "Exchange rate is not higher than zero") - self.assertTrue( - isinstance(usd.timestamp, datetime), - "Externa price timestamp is not datetime", - ) - - def test_create_order( - self, - robot_index=1, - payment_method="Advcash Cash F2F", - min_amount=21, - max_amount=101.7, - premium=3.34, - public_duration=69360, - escrow_duration=8700, - bond_size=3.5, - latitude=34.7455, - longitude=135.503, - ): - # Requisites - # Cache market prices - self.test_cache_market() - path = "/api/make/" + def test_create_robots(self): + """ + Test the creation of two robots to be used in the trade tests + """ + for robot_index in [1, 2]: + response = self.create_robot(robot_index) + self.assert_robot(response, robot_index) + + def make_order(self, maker_form, robot_index=1): + """ + Create an order for the test. + """ + path = reverse("make") # Get valid robot auth headers - headers, _, _ = self.get_robot_auth(robot_index) - - # Prepare request body - maker_form = { - "type": Order.Types.BUY, - "currency": 1, - "has_range": True, - "min_amount": min_amount, - "max_amount": max_amount, - "payment_method": payment_method, - "is_explicit": False, - "premium": premium, - "public_duration": public_duration, - "escrow_duration": escrow_duration, - "bond_size": bond_size, - "latitude": latitude, - "longitude": longitude, - } + headers = self.get_robot_auth(robot_index, True) response = self.client.post(path, maker_form, **headers) + return response + + def test_make_order(self): + """ + Test the creation of an order. + """ + maker_form = self.maker_form_with_range + response = self.make_order(maker_form, robot_index=1) data = json.loads(response.content.decode()) # Checks - self.assertTrue(isinstance(data["id"], int), "Order ID is not an integer") + self.assertResponse(response) + + self.assertIsInstance(data["id"], int, "Order ID is not an integer") self.assertEqual( data["status"], Order.Status.WFB, "Newly created order status is not 'Waiting for maker bond'", ) - self.assertTrue( - isinstance(datetime.fromisoformat(data["created_at"]), datetime), + self.assertIsInstance( + datetime.fromisoformat(data["created_at"]), + datetime, "Order creation timestamp is not datetime", ) - self.assertTrue( - isinstance(datetime.fromisoformat(data["expires_at"]), datetime), + self.assertIsInstance( + datetime.fromisoformat(data["expires_at"]), + datetime, "Order expiry time is not datetime", ) self.assertEqual( @@ -166,50 +259,216 @@ def test_create_order( ) self.assertEqual(data["currency"], 1, "Order for USD is not of currency USD") self.assertIsNone( - data["amount"], "Order with range has a non null simple amount" + data["amount"], "Order with range has a non-null simple amount" ) self.assertTrue(data["has_range"], "Order with range has a False has_range") - self.assertEqual( - float(data["min_amount"]), min_amount, "Order min amount does not match" + self.assertAlmostEqual( + float(data["min_amount"]), + maker_form["min_amount"], + "Order min amount does not match", ) - self.assertEqual( - float(data["max_amount"]), max_amount, "Order max amount does not match" + self.assertAlmostEqual( + float(data["max_amount"]), + maker_form["max_amount"], + "Order max amount does not match", ) self.assertEqual( data["payment_method"], - payment_method, + maker_form["payment_method"], "Order payment method does not match", ) self.assertEqual( data["escrow_duration"], - escrow_duration, + maker_form["escrow_duration"], "Order escrow duration does not match", ) - self.assertEqual( - float(data["bond_size"]), bond_size, "Order bond size does not match" + self.assertAlmostEqual( + float(data["bond_size"]), + maker_form["bond_size"], + "Order bond size does not match", ) - self.assertEqual( - float(data["latitude"]), latitude, "Order latitude does not match" + self.assertAlmostEqual( + float(data["latitude"]), + maker_form["latitude"], + "Order latitude does not match", ) - self.assertEqual( - float(data["longitude"]), longitude, "Order longitude does not match" + self.assertAlmostEqual( + float(data["longitude"]), + maker_form["longitude"], + "Order longitude does not match", ) - self.assertEqual( - float(data["premium"]), premium, "Order premium does not match" + self.assertAlmostEqual( + float(data["premium"]), + maker_form["premium"], + "Order premium does not match", ) self.assertFalse( data["is_explicit"], "Relative pricing order has True is_explicit" ) self.assertIsNone( - data["satoshis"], "Relative pricing order has non null Satoshis" + data["satoshis"], "Relative pricing order has non-null Satoshis" ) self.assertIsNone(data["taker"], "New order's taker is not null") - with open(f"tests/robots/{robot_index}/nickname", "r") as file: - maker_nickname = file.read() - maker_id = User.objects.get(username=maker_nickname).id + return data + + def get_order(self, order_id, robot_index=1, first_encounter=False): + path = reverse("order") + params = f"?order_id={order_id}" + headers = self.get_robot_auth(robot_index, first_encounter) + response = self.client.get(path + params, **headers) + + return response + + def test_get_order_created(self): + # Make an order + maker_form = self.maker_form_with_range + robot_index = 1 + + order_made_response = self.make_order(maker_form, robot_index) + order_made_data = json.loads(order_made_response.content.decode()) + + # Maker's first order fetch. Should trigger maker bond hold invoice generation. + response = self.get_order(order_made_data["id"]) + data = json.loads(response.content.decode()) + + self.assertEqual(response.status_code, 200) + self.assertResponse(response) + + self.assertEqual(data["id"], order_made_data["id"]) + self.assertTrue( + isinstance(datetime.fromisoformat(data["created_at"]), datetime) + ) + self.assertTrue( + isinstance(datetime.fromisoformat(data["expires_at"]), datetime) + ) + self.assertTrue(data["is_maker"]) + self.assertTrue(data["is_participant"]) + self.assertTrue(data["is_buyer"]) + self.assertFalse(data["is_seller"]) + self.assertEqual(data["maker_status"], "Active") + self.assertEqual(data["status_message"], Order.Status(Order.Status.WFB).label) + self.assertFalse(data["is_fiat_sent"]) + self.assertFalse(data["is_disputed"]) self.assertEqual( - data["maker"], - maker_id, - "Maker user ID is not that of robot index {robot_index}", + data["ur_nick"], read_file(f"tests/robots/{robot_index}/nickname") ) + self.assertTrue(isinstance(data["satoshis_now"], int)) + self.assertFalse(data["maker_locked"]) + self.assertFalse(data["taker_locked"]) + self.assertFalse(data["escrow_locked"]) + self.assertTrue(isinstance(data["bond_satoshis"], int)) + + def check_for_locked_bonds(self): + # A background thread checks every 5 second the status of invoices. We invoke directly during test. + # It will ask LND via gRPC. In our test, the request/response from LND is mocked, and it will return fake invoice status "ACCEPTED" + follow_invoices = FollowInvoices() + follow_invoices.follow_hold_invoices() + + def make_and_publish_order(self, maker_form, robot_index=1): + # Make an order + order_made_response = self.make_order(maker_form, robot_index) + order_made_data = json.loads(order_made_response.content.decode()) + + # Maker's first order fetch. Should trigger maker bond hold invoice generation. + response = self.get_order(order_made_data["id"]) + invoice = response.json()["bond_invoice"] + + # Lock the invoice from the robot's node + pay_invoice("robot", invoice) + + # Check for invoice locked (the mocked LND will return ACCEPTED) + self.check_for_locked_bonds() + + # Get order + response = self.get_order(order_made_data["id"]) + return response + + def test_publish_order(self): + maker_form = self.maker_form_with_range + # Get order + response = self.make_and_publish_order(maker_form) + data = json.loads(response.content.decode()) + + self.assertEqual(response.status_code, 200) + self.assertResponse(response) + + self.assertEqual(data["id"], data["id"]) + self.assertEqual(data["status_message"], Order.Status(Order.Status.PUB).label) + self.assertTrue(data["maker_locked"]) + self.assertFalse(data["taker_locked"]) + self.assertFalse(data["escrow_locked"]) + + # Test what we can see with newly created robot 2 (only for public status) + public_response = self.get_order( + data["id"], robot_index=2, first_encounter=True + ) + public_data = json.loads(public_response.content.decode()) + + self.assertFalse(public_data["is_participant"]) + self.assertTrue(isinstance(public_data["price_now"], float)) + self.assertTrue(isinstance(data["satoshis_now"], int)) + + # @patch("api.lightning.cln.hold_pb2_grpc.HoldStub", MockHoldStub) + # @patch("api.lightning.lnd.lightning_pb2_grpc.LightningStub", MockLightningStub) + # @patch("api.lightning.lnd.invoices_pb2_grpc.InvoicesStub", MockInvoicesStub) + def take_order(self, order_id, amount, robot_index=2): + path = reverse("order") + params = f"?order_id={order_id}" + headers = self.get_robot_auth(robot_index, first_encounter=True) + body = {"action": "take", "amount": amount} + response = self.client.post(path + params, body, **headers) + + return response + + def make_and_take_order( + self, maker_form, take_amount=80, maker_index=1, taker_index=2 + ): + response_published = self.make_and_publish_order(maker_form, maker_index) + data_publised = json.loads(response_published.content.decode()) + response = self.take_order(data_publised["id"], take_amount, taker_index) + return response + + def test_make_and_take_order(self): + maker_index = 1 + taker_index = 2 + maker_form = self.maker_form_with_range + + response = self.make_and_take_order(maker_form, 80, maker_index, taker_index) + data = json.loads(response.content.decode()) + + self.assertEqual(response.status_code, 200) + self.assertResponse(response) + + self.assertEqual( + data["ur_nick"], read_file(f"tests/robots/{taker_index}/nickname") + ) + self.assertEqual( + data["taker_nick"], read_file(f"tests/robots/{taker_index}/nickname") + ) + self.assertEqual( + data["maker_nick"], read_file(f"tests/robots/{maker_index}/nickname") + ) + self.assertFalse(data["is_maker"]) + self.assertTrue(data["is_taker"]) + self.assertTrue(data["is_participant"]) + + # a = { + # "maker_status": "Active", + # "taker_status": "Active", + # "price_now": 38205.0, + # "premium_now": 3.34, + # "satoshis_now": 266196, + # "is_buyer": False, + # "is_seller": True, + # "taker_nick": "EquivalentWool707", + # "status_message": "Waiting for taker bond", + # "is_fiat_sent": False, + # "is_disputed": False, + # "ur_nick": "EquivalentWool707", + # "maker_locked": True, + # "taker_locked": False, + # "escrow_locked": False, + # "bond_invoice": "lntb73280n1pj5uypwpp5vklcx3s3c66ltz5v7kglppke5n3u6sa6h8m6whe278lza7rwfc7qd2j2pshjmt9de6zqun9vejhyetwvdjn5gp3vgcxgvfkv43z6e3cvyez6dpkxejj6cnxvsmj6c3exsuxxden89skzv3j9cs9g6rfwvs8qcted4jkuapq2ay5cnpqgefy2326g5syjn3qt984253q2aq5cnz92skzqcmgv43kkgr0dcs9ymmzdafkzarnyp5kvgr5dpjjqmr0vd4jqampwvs8xatrvdjhxumxw4kzugzfwss8w6tvdssxyefqw4hxcmmrddjkggpgveskjmpfyp6kumr9wdejq7t0w5sxx6r9v96zqmmjyp3kzmnrv4kzqatwd9kxzar9wfskcmre9ccqz2sxqzfvsp5hkz0dnvja244hc8jwmpeveaxtjd4ddzuqlpqc5zxa6tckr8py50s9qyyssqdcl6w2rhma7k3v904q4tuz68z82d6x47dgflk6m8jdtgt9dg3n9304axv8qvd66dq39sx7yu20sv5pyguv9dnjw3385y8utadxxsqtsqpf7p3w", + # "bond_satoshis": 7328, + # }