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,
+ # }