Merge branch 'main' into perf/delete-backend-avatars

This commit is contained in:
Reckless_Satoshi 2024-06-16 22:53:51 +00:00 committed by GitHub
commit 7558ef415c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
125 changed files with 3637 additions and 2840 deletions

View File

@ -58,6 +58,12 @@ SECRET_KEY = 'django-insecure-6^&6uw$b5^en%(cu2kc7_o)(mgpazx#j_znwlym0vxfamn2uo-
# e.g. robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion
ONION_LOCATION = ''
# Geoblocked countries (will reject F2F trades).
# List of A3 country codes (see fhttps://en.wikipedia.org/wiki/ISO_3166-1_alpha-3)
# Leave empty '' to allow all countries.
# Example 'NOR,USA,CZE'.
GEOBLOCKED_COUNTRIES = 'ABW,AFG,AGO'
# Link to robosats alternative site (shown in frontend in statsfornerds so users can switch mainnet/testnet)
ALTERNATIVE_SITE = 'RoboSats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion'
ALTERNATIVE_NAME = 'RoboSats Mainnet'

View File

@ -6,6 +6,15 @@ on:
semver:
required: true
type: string
secrets:
KEYSTORE:
required: true
KEY_ALIAS:
required: true
KEY_PASS:
required: true
KEY_STORE_PASS:
required: true
push:
branches: [ "main" ]
paths: [ "mobile", "frontend" ]
@ -54,10 +63,37 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1.2
with:
fileName: 'keystore.jks'
fileDir: './'
encodedString: ${{ secrets.KEYSTORE }}
- name: 'Build Android Release'
run: |
cd mobile/android
./gradlew assembleRelease
env:
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASS: ${{ secrets.KEY_PASS }}
KEY_STORE_PASS: ${{ secrets.KEY_STORE_PASS }}
- name: 'Check for non-FOSS libraries'
run: |
wget https://github.com/iBotPeaches/Apktool/releases/download/v2.7.0/apktool_2.7.0.jar
wget https://github.com/iBotPeaches/Apktool/raw/master/scripts/linux/apktool
# clone the repo
git clone https://gitlab.com/IzzyOnDroid/repo.git
# create a directory for Apktool and move the apktool* files there
mkdir -p repo/lib/radar/tool
mv apktool* repo/lib/radar/tool
# create an alias for ease of use
chmod u+x repo/lib/radar/tool/apktool
mv repo/lib/radar/tool/apktool_2.7.0.jar repo/lib/radar/tool/apktool.jar
repo/bin/scanapk.php mobile/android/app/build/outputs/apk/release/app-universal-release.apk
- name: 'Get Commit Hash'
id: commit

View File

@ -22,7 +22,7 @@ jobs:
matrix:
python-tag: ['3.12.3-slim-bookworm', '3.13-rc-slim-bookworm']
lnd-version: ['v0.17.4-beta']
cln-version: ['v23.11.2','v24.02']
cln-version: ['v23.11.2'] #,'v24.02']
ln-vendor: ['LND'] #, 'CLN']
steps:

View File

@ -71,6 +71,7 @@ jobs:
android-build:
uses: RoboSats/robosats/.github/workflows/android-build.yml@main
needs: [frontend-build, check-versions]
secrets: inherit
with:
semver: ${{ needs.check-versions.outputs.semver }}

3
.gitignore vendored
View File

@ -1,9 +1,6 @@
*.py[cod]
__pycache__
# C extensions
*.so
# Packages
*.egg
*.egg-info

View File

@ -52,8 +52,19 @@ class ETokenAdmin(AdminChangeLinksMixin, TokenAdmin):
change_links = ("user",)
class LNPaymentInline(admin.StackedInline):
model = LNPayment
can_delete = True
fields = ("num_satoshis", "status", "routing_budget_sats", "description")
readonly_fields = ("num_satoshis", "status", "routing_budget_sats", "description")
show_change_link = True
show_full_result_count = True
extra = 0
@admin.register(Order)
class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
inlines = [LNPaymentInline]
list_display = (
"id",
"type",
@ -358,8 +369,61 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
return float(obj.amount)
class OrderInline(admin.StackedInline):
model = Order
can_delete = False
show_change_link = True
extra = 0
fields = (
"id",
"type",
"maker",
"taker",
"status",
"amount",
"currency",
"last_satoshis",
"is_disputed",
"is_fiat_sent",
"created_at",
"expires_at",
"payout_tx",
"payout",
"maker_bond",
"taker_bond",
"trade_escrow",
)
readonly_fields = fields
class PayoutOrderInline(OrderInline):
verbose_name = "Order Paid"
fk_name = "payout"
class MakerBondOrderInline(OrderInline):
verbose_name = "Order Made"
fk_name = "maker_bond"
class TakerBondOrderInline(OrderInline):
verbose_name = "Order Taken"
fk_name = "taker_bond"
class EscrowOrderInline(OrderInline):
verbose_name = "Order Escrow"
fk_name = "trade_escrow"
@admin.register(LNPayment)
class LNPaymentAdmin(AdminChangeLinksMixin, admin.ModelAdmin):
inlines = [
PayoutOrderInline,
MakerBondOrderInline,
TakerBondOrderInline,
EscrowOrderInline,
]
list_display = (
"hash",
"concept",

View File

@ -1,7 +1,7 @@
import math
from datetime import timedelta
from decouple import config
from decouple import config, Csv
from django.contrib.auth.models import User
from django.db.models import Q, Sum
from django.utils import timezone
@ -9,7 +9,7 @@ from django.utils import timezone
from api.lightning.node import LNNode
from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order
from api.tasks import send_devfund_donation, send_notification
from api.utils import get_minning_fee, validate_onchain_address
from api.utils import get_minning_fee, validate_onchain_address, location_country
from chat.models import Message
FEE = float(config("FEE"))
@ -29,6 +29,8 @@ MAX_MINING_NETWORK_SPEEDUP_EXPECTED = float(
config("MAX_MINING_NETWORK_SPEEDUP_EXPECTED")
)
GEOBLOCKED_COUNTRIES = config("GEOBLOCKED_COUNTRIES", cast=Csv(), default="")
class Logics:
@classmethod
@ -137,6 +139,19 @@ class Logics:
return True, None
@classmethod
def validate_location(cls, order) -> bool:
if not (order.latitude or order.longitude):
return True, None
country = location_country(order.longitude, order.latitude)
if country in GEOBLOCKED_COUNTRIES:
return False, {
"bad_request": f"The coordinator does not support orders in {country}"
}
else:
return True, None
def validate_amount_within_range(order, amount):
if amount > float(order.max_amount) or amount < float(order.min_amount):
return False, {
@ -878,7 +893,7 @@ class Logics:
if order.status == Order.Status.FAI:
if order.payout.status != LNPayment.Status.EXPIRE:
return False, {
"bad_request": "You can only submit an invoice after expiration or 3 failed attempts"
"bad_invoice": "You can only submit an invoice after expiration or 3 failed attempts"
}
# cancel onchain_payout if existing
@ -894,25 +909,24 @@ class Logics:
if not payout["valid"]:
return False, payout["context"]
order.payout, _ = LNPayment.objects.update_or_create(
if order.payout:
if order.payout.payment_hash == payout["payment_hash"]:
return False, {"bad_invoice": "You must submit a NEW invoice"}
order.payout = LNPayment.objects.create(
concept=LNPayment.Concepts.PAYBUYER,
type=LNPayment.Types.NORM,
sender=User.objects.get(username=ESCROW_USERNAME),
# In case this user has other payouts, update the one related to this order.
order_paid_LN=order,
receiver=user,
routing_budget_ppm=routing_budget_ppm,
routing_budget_sats=routing_budget_sats,
# if there is a LNPayment matching these above, it updates that one with defaults below.
defaults={
"invoice": invoice,
"status": LNPayment.Status.VALIDI,
"num_satoshis": num_satoshis,
"description": payout["description"],
"payment_hash": payout["payment_hash"],
"created_at": payout["created_at"],
"expires_at": payout["expires_at"],
},
invoice=invoice,
status=LNPayment.Status.VALIDI,
num_satoshis=num_satoshis,
description=payout["description"],
payment_hash=payout["payment_hash"],
created_at=payout["created_at"],
expires_at=payout["expires_at"],
)
order.is_swap = False

View File

@ -1,5 +1,6 @@
import json
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
@ -18,7 +19,7 @@ class Currency(models.Model):
decimal_places=4,
default=None,
null=True,
validators=[MinValueValidator(0)],
validators=[MinValueValidator(Decimal(0))],
)
timestamp = models.DateTimeField(default=timezone.now)

View File

@ -1,5 +1,6 @@
import uuid
from decimal import Decimal
from decouple import config
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@ -27,21 +28,24 @@ class MarketTick(models.Model):
decimal_places=2,
default=None,
null=True,
validators=[MinValueValidator(0)],
validators=[MinValueValidator(Decimal(0))],
)
volume = models.DecimalField(
max_digits=8,
decimal_places=8,
default=None,
null=True,
validators=[MinValueValidator(0)],
validators=[MinValueValidator(Decimal(0))],
)
premium = models.DecimalField(
max_digits=5,
decimal_places=2,
default=None,
null=True,
validators=[MinValueValidator(-100), MaxValueValidator(999)],
validators=[
MinValueValidator(Decimal(-100)),
MaxValueValidator(Decimal(999))
],
blank=True,
)
currency = models.ForeignKey("api.Currency", null=True, on_delete=models.SET_NULL)
@ -52,7 +56,10 @@ class MarketTick(models.Model):
max_digits=4,
decimal_places=4,
default=0,
validators=[MinValueValidator(0), MaxValueValidator(1)],
validators=[
MinValueValidator(Decimal(0)),
MaxValueValidator(Decimal(1))
],
)
def log_a_tick(order):

View File

@ -1,3 +1,4 @@
from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import MaxValueValidator, MinValueValidator
@ -58,7 +59,10 @@ class OnchainPayment(models.Model):
default=2.05,
null=False,
blank=False,
validators=[MinValueValidator(1), MaxValueValidator(999)],
validators=[
MinValueValidator(Decimal(1)),
MaxValueValidator(Decimal(999))
],
)
mining_fee_rate = models.DecimalField(
max_digits=6,
@ -66,7 +70,10 @@ class OnchainPayment(models.Model):
default=2.05,
null=False,
blank=False,
validators=[MinValueValidator(1), MaxValueValidator(999)],
validators=[
MinValueValidator(Decimal(1)),
MaxValueValidator(Decimal(999))
],
)
mining_fee_sats = models.PositiveBigIntegerField(default=0, null=False, blank=False)

View File

@ -1,6 +1,7 @@
# We use custom seeded UUID generation during testing
import uuid
from decimal import Decimal
from decouple import config
from django.conf import settings
from django.contrib.auth.models import User
@ -90,7 +91,10 @@ class Order(models.Model):
decimal_places=2,
default=0,
null=True,
validators=[MinValueValidator(-100), MaxValueValidator(999)],
validators=[
MinValueValidator(Decimal(-100)),
MaxValueValidator(Decimal(999))
],
blank=True,
)
# explicit
@ -135,8 +139,8 @@ class Order(models.Model):
default=settings.DEFAULT_BOND_SIZE,
null=False,
validators=[
MinValueValidator(settings.MIN_BOND_SIZE), # 2 %
MaxValueValidator(settings.MAX_BOND_SIZE), # 15 %
MinValueValidator(Decimal(settings.MIN_BOND_SIZE)), # 2 %
MaxValueValidator(Decimal(settings.MAX_BOND_SIZE)), # 15 %
],
blank=False,
)
@ -147,8 +151,8 @@ class Order(models.Model):
decimal_places=6,
null=True,
validators=[
MinValueValidator(-90),
MaxValueValidator(90),
MinValueValidator(Decimal(-90)),
MaxValueValidator(Decimal(90)),
],
blank=True,
)
@ -157,8 +161,8 @@ class Order(models.Model):
decimal_places=6,
null=True,
validators=[
MinValueValidator(-180),
MaxValueValidator(180),
MinValueValidator(Decimal(-180)),
MaxValueValidator(Decimal(180)),
],
blank=True,
)

View File

@ -219,14 +219,17 @@ class OrderViewSchema:
- `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
valid LN invoice as cleartext PGP message signed (SHA512) with the robot key.
The amount of the invoice should be `invoice_amount` minus the routing
budget whose parts per million should be specified by `routing_budget_ppm`.
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
cleartext PGP message signed (SHA512) 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.
@ -246,9 +249,7 @@ class OrderViewSchema:
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.
maker bond, you are returned your 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
@ -387,12 +388,13 @@ class RobotViewSchema:
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
Make sure you generate your token using cryptographically secure methods.
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)
[here](https://github.com/RoboSats/robosats/blob/main/frontend/src/utils/token.ts)
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.
The PGP key should be an EdDSA ed25519/cert,sign+cv25519/encr key.
`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
@ -403,7 +405,7 @@ class RobotViewSchema:
A gpg key can be created by:
```shell
gpg --full-gen-key
gpg --default-new-key-algo "ed25519/cert,sign+cv25519/encr" --full-gen-key
```
it's public key can be exported in ascii armored format with:
@ -531,7 +533,7 @@ class InfoViewSchema:
class RewardViewSchema:
post = {
"summary": "Withdraw reward",
"description": "Withdraw user reward by submitting an invoice. The invoice must be send as cleartext PGP message signed with the robot key",
"description": "Withdraw user reward by submitting an invoice. The invoice must be send as cleartext PGP message signed (SHA512) with the robot key",
"responses": {
200: {
"type": "object",

View File

@ -494,7 +494,8 @@ class OrderPublicSerializer(serializers.ModelSerializer):
maker_nick = serializers.CharField(required=False)
maker_hash_id = serializers.CharField(required=False)
maker_status = serializers.CharField(
help_text='Status of the nick - "Active" or "Inactive"', required=False
help_text='Status of the nick - "Active", "Seen Recently" or "Inactive"',
required=False,
)
price = serializers.FloatField(
help_text="Price in order's fiat currency", required=False

View File

@ -479,6 +479,33 @@ def is_valid_token(token: str) -> bool:
return all(c in charset for c in token)
def location_country(lon: float, lat: float) -> str:
"""
Returns the country code of a lon/lat location
"""
from shapely.geometry import shape, Point
from shapely.prepared import prep
# Load the GeoJSON data from a local file
with open("frontend/static/assets/geo/countries-coastline-10km.geo.json") as f:
countries_geojeson = json.load(f)
# Prepare the countries for reverse geocoding
countries = {}
for feature in countries_geojeson["features"]:
geom = feature["geometry"]
country_code = feature["properties"]["A3"]
countries[country_code] = prep(shape(geom))
point = Point(lon, lat)
for country_code, geom in countries.items():
if geom.contains(point):
return country_code
return "unknown"
def objects_to_hyperlinks(logs: str) -> str:
"""
Parses strings that have Object(ID,NAME) that match API models.

View File

@ -162,6 +162,10 @@ class MakerView(CreateAPIView):
if not valid:
return Response(context, status.HTTP_400_BAD_REQUEST)
valid, context = Logics.validate_location(order)
if not valid:
return Response(context, status.HTTP_400_BAD_REQUEST)
order.save()
order.log(
f"Order({order.id},{order}) created by Robot({request.user.robot.id},{request.user})"

View File

@ -90,7 +90,7 @@ GEM
activesupport (>= 2)
nokogiri (>= 1.4)
http_parser.rb (0.8.0)
i18n (1.14.1)
i18n (1.14.4)
concurrent-ruby (~> 1.0)
jekyll (3.9.5)
addressable (~> 2.4)
@ -213,7 +213,7 @@ GEM
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minimal-mistakes-jekyll (4.24.0)
minimal-mistakes-jekyll (4.25.1)
jekyll (>= 3.7, < 5.0)
jekyll-feed (~> 0.1)
jekyll-gist (~> 1.5)
@ -230,7 +230,7 @@ GEM
sawyer (~> 0.9)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (5.0.4)
public_suffix (5.0.5)
racc (1.7.3)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)

View File

@ -62,6 +62,14 @@ In Canada, [Interac e-Transfer](https://www.interac.ca/en/consumers/support/faq-
The best practice for users trying to transact with a payment method with a high risk of losing funds is discussed in this section.
### Instant SEPA Payment Guidelines
Instant SEPA is a widely adopted payment method across Europe, offering fast and efficient cashless transactions. However, it comes with a significant risk for sellers, including the potential for chargebacks. To mitigate these risks, it is advisable for sellers to request the buyer's information before sharing their SEPA details. This information could include the buyer's country, full name, and bank account number. By obtaining this information, sellers can reduce the risk of fraudulent transactions, such as triangle attacks, while buyers, sharing this information does not decrease their privacy, as they are not exposing any additional information that the seller would not have access to anyway after the SEPA transfer.
For buyers, it is crucial to comply with sellers' if they request personal information when they are initiating SEPA transactions. Failure to provide this information can lead to the seller raising an immediate dispute, which sellers are likely to win (the seller will also earn the buyer's bond in this specific case). Therefore, it is in the best interest of buyers to cooperate with sellers' requests for information.
Sellers are encouraged to share a link to this guide with their buyers when requesting information. This ensures that both parties are informed and understand the importance of this step when using Instant SEPA.
### Revolut via payment links
In a Revolut payment, a `@revtag` is usually exchanged in the chat and can be verified in the payment history of the app making proof of payments easy.
@ -70,12 +78,14 @@ However, payment links, which have the format https://revolut.me/p/XXXXX, don't
In a dispute, there's no recipient address reference and both buyer and seller could cheat. The payment link could be redeemed by an unknown third party complicit with either buyer or seller.
Therefore, insist on receiving the `@revtag` when making a payment with Revolut to avoid these risks. The `@revtag` can also be received as a link. This link would look like this: https://revolut.me/@revtag.
Therefore, insist on receiving the `@revtag` when making a payment with Revolut to avoid these risks. The `@revtag` can also be received as a link. This link would look like this: https://revolut.me/@revtag.
### Paypal
Paypal is one of the widely used fiat payment methods. However, with <a href="https://www.paypal.com/us/webapps/mpp/ua/buyer-protection">PayPal buyer protection policy</a>, buyer can do fraudulent action by creating a refund request in PayPal after the trading process in RoboSats is finished and therefore taking both fiat and bitcoin all by themselves.
Paypal is one of the widely used fiat payment methods. However, as a seller Paypal is the highest risk you can take. Using Paypal as payment method is not advised.
This fraud can be prevented by agreeing with the buyer to have them send money using the “send money to a friend or family member” option. This will make the buyer become the one liable for the transaction fee and make it less likely for them to request a refund.
If you still wish to use Paypal there is a few things to take into account. With <a href="https://www.paypal.com/us/webapps/mpp/ua/buyer-protection">PayPal buyer protection policy</a>, buyers can do fraudulent action by creating a refund request in PayPal after the trading process in RoboSats is finished and therefore taking both fiat and bitcoin all by themselves.
This fraud could be prevented by agreeing with the buyer to have them send money using the “send money to a friend or family member” option. This will make the buyer become the one liable for the transaction fee and make it less likely for them to request a refund.
### For seller
If you are a seller and your peer both agreed to use “send money to a friend or family member” but your peer used the "send money for Goods or Services" option, you should return the fiat payment and ask your peer to send with an agreed method. If they insist to break the agreement, you may ask them to voluntarily end the trade or end the trade by calling a dispute.
@ -83,26 +93,4 @@ If you are a seller and your peer both agreed to use “send money to a friend o
### For buyer
If you are a buyer and you need to use “send money to a friend or family member” to pay fiat to your peer, you can choose the specified payment type by following these steps.
#### PayPal Desktop
In PayPal desktop, it is located below the drop-down currency list, it should be labeled as "Sending to a friend".
If it is labeled otherwise, you'll need to click "Change" on the right to change the payment type.
<div align="center">
<img src="/assets/images/fiat-payment-methods/PayPal-main-desktop.png" width="370"/>
</div>
Then select "Sending to a friend" in the payment type choosing page.
<div align="center">
<img src="/assets/images/fiat-payment-methods/PayPal-choose-desktop.png" width="370"/>
</div>
#### PayPal Mobile
In PayPal mobile, it is located below the payment method (In this case is VISA), it should be labeled as "Friends or Family".
If it is labeled otherwise, you'll need to tab ">" on the right to change the payment type.
<div align="center">
<img src="/assets/images/fiat-payment-methods/PayPal-main-phone.png" width="230"/>
</div>
Then select "Friends or Family" in the payment type choosing page.
<div align="center">
<img src="/assets/images/fiat-payment-methods/PayPal-choose-phone.png" width="230"/>
</div>
{% include improve %}

View File

@ -28,15 +28,17 @@ This is a non-exhaustive compilation based on past experience of users. We have
| Wallet | Version | Device | UX<sup>1</sup> | Bonds<sup>2</sup> | Payout<sup>3</sup> | Comp<sup>4</sup> | Total<sup>5</sup> |
|:---|:---|:--:|:--:|:--:|:--:|:--:|:--:|
|[Alby](#alby-browser-extension)|[v1.14.2](https://github.com/getAlby/lightning-browser-extension)|{{page.laptop}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}} |{{page.thumbsup}}|
|[Aqua](#aqua-mobile)|[v0.1.55](https://aquawallet.io/)|{{page.phone}}|{{page.good}}|{{page.good}}|{{page.unclear}}|{{page.good}} |{{page.thumbsup}}|
|[Blink](#blink-mobile-former-bitcoin-beach-wallet)|[2.2.73](https://www.blink.sv/)|{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}} |{{page.thumbsup}}|
|[Blixt](#blixt-androidios-lnd-light-backend-on-device)|[v0.4.1](https://github.com/hsjoberg/blixt-wallet)|{{page.phone}}|{{page.soso}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}|
|[Blue](#bluewallet-mobile)|[1.4.4](https://bluewallet.io/)|{{page.phone}}|{{page.good}}|{{page.unclear}}|{{page.unclear}}|{{page.good}}|{{page.unclear}}|
|[Breez](#breez-mobile)|[0.16](https://breez.technology/mobile/)|{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}|
|[Cash App](#cash-app-mobile)|[4.7](https://cash.app/)|{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}} |{{page.thumbsup}}|
|[Core Lightning](#core-lightning--cln-cli-interface)|[v0.11.1](https://github.com/ElementsProject/lightning)|{{page.cli}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}|
|[Electrum](#electrum-desktop)|[4.1.4](https://github.com/spesmilo/electrum)|{{page.laptop}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}||
|[Electrum](#electrum-mobile--desktop)|[4.1.4](https://github.com/spesmilo/electrum)|{{page.laptop}}{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}||
|[LND](#lnd-cli-interface)|[v0.14.2](https://github.com/LightningNetwork/lnd)|{{page.cli}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}|
|[Mash](https://app.mash.com/wallet)|[Beta](https://mash.com/consumer-experience/)|{{page.laptop}}{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}} | {{page.thumbsup}}|
|[Mutiny](#mutiny-mobile--web-browser-wallet)|[1.7.1](https://www.mutinywallet.com/)|{{page.laptop}}{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsdown}}||
|[Muun](#muun-mobile)|[47.3](https://muun.com/)|{{page.phone}}|{{page.good}}|{{page.good}}|{{page.bad}}|{{page.bad}}|{{page.thumbsdown}}|
|[Phoenix](#phoenix-mobile)|[35-1.4.20](https://phoenix.acinq.co/)|{{page.phone}}|{{page.good}}|{{page.soso}}|{{page.soso}}|{{page.soso}}|{{page.unclear}}|
|[SBW](https://github.com/RoboSats/robosats/issues/44#issue-1135544303)|[2.4.27](https://github.com/btcontract/wallet/)|{{page.phone}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.good}}|{{page.thumbsup}}|
@ -57,6 +59,12 @@ Instructions to install Alby in Tor Browser:
1. Install the Alby extension from the [Firefox add-ons store](https://addons.mozilla.org/en-US/firefox/addon/alby/)
2. Click on the Alby extension and follow the prompts to setup your wallet.
### Aqua (Mobile)
Overall the wallet works as expected. Hold invoices work reliably.
What is inconvenient:
- Lightning payments are encapsulated into Liquid Bitcoin so there is a small additional fee for conversion in/out
- Bond refund is locked for 3 days
### Blink (Mobile, former Bitcoin Beach Wallet)
Works well with RoboSats. Hodl invoices (Bonds) show as "Pending" in the transaction history. Payouts to the Blink wallet function as intended. Custodial wallet by Galoy which originated from the Bitcoin Beach project in El Salvador (formerly known as "Bitcoin Beach Wallet").
@ -75,8 +83,11 @@ Works well with RoboSats. Hodl invoices (Bonds) show as "Pending" in the transac
### Core Lightning / CLN (CLI Interface)
Works as expected. The `lightning-cli pay <invoice>` command does not conclude while the payment is pending, but can use `lightning-cli paystatus <invoice>` to monitor the state.
### Electrum (Desktop)
Works as expected. Some payments and locks may fail depending on the Lightning node the channel is created to. Channels to ASINQ work fine.
### Electrum (Mobile & Desktop)
Overall the wallet works as expected. The interface is precise and clear.
What is inconvenient:
- all your Lightning channels have to be created to the node: ACINQ
### LND (CLI Interface)
Raw; it shows exactly what is happening and what it knows "IN_FLIGHT". It is not user friendly and therefore not recommended to interact with RoboSats by beginners. However, everything works just fine. If you are using LNCLI regularly, then you will find no issue using it with RoboSats.
@ -85,6 +96,13 @@ Raw; it shows exactly what is happening and what it knows "IN_FLIGHT". It is not
### Mash Wallet App (Mobile PWA & Desktop Web-Wallet)
Overall the [Mash](https://mash.com/consumer-experience/) wallet works end2end with Robosats on both selling & buying over lightning. Majority of relevant invoice details in the mash wallet are shown and clear to users throughout the process. When the transactions are complete, they open in the mobile app on both sender/receiver sides to highlight that the transactions are completed.The one UX hick-up is that the pending invoices list doesn't explicitly show HOLD invoices and there is a "spinning" screen on first HOLD invoice payment. The team has a bug open to fix this issue shortly (this note is from Aug 21st 2023).
### Mutiny (Mobile & Web Browser Wallet)
The wallet should work as expected, but the interface, transaction states, and the structure of the funds can sometimes be very confusing in the current release version.
Use the default free Fedimint(Chaumian eCash) account, with the possibility to use zero fee Lightning transfers.
What is inconvenient:
- occasionally wallet restart is needed
- more than two pending hold invoices at the same time may cause a rejection of the new transaction
### Muun (Mobile)
Similar to Blixt or LND, Muun plays nicely with hold invoices. You can be a seller in RoboSats using Muun and the user experience will be great. However, in order to be a buyer when using Muun, you need to submit an on-chain address for the payout as a Lightning invoice won't work. Muun is _fee siphoning attacking_ any sender to Muun wallet. There is a mandatory hop through a private channel with a fee of +1500ppm. RoboSats will strictly not route a buyer payout for a net loss. Given that RoboSats trading fees are {{site.robosats.total_fee}}% and it needs to cover the routing fees, **RoboSats will never find a suitable route to a Muun wallet user**. At the moment, RoboSats will scan your invoice for routing hints that can potentially encode a _fee siphoning attack_. If this trick is found, then the invoice will be rejected: submit an on-chain address instead for an on-the-fly swap. Refer to [Understand > On-Chain Payouts](/docs/on-chain-payouts/) for more information about on-the-fly swaps. Important to note that Muun has issues during times of high on chain fee spikes. Regardless, the workaround to receive to Muun is: either submit an on chain address or choose a higher routing budget after enabling the "Advanced Options" switch.
@ -93,6 +111,7 @@ One of the simplest and one of the best. The hodl invoice shows as "on fly", it
*Update 26-10-23: At this moment it has no development or support
### Phoenix (Mobile)
DEV team stated that they do not want to support hold invoices.
*Update 21-10-23. Phoenix used to work as described here, but many things changed to worse with the last update of the wallet.
Phoenix works very well as an order taker. Phoenix will also work well as an order maker as long as the order settings `public duration` + `deposit duration` are lower than 10 hours. Otherwise, you might have problems locking the maker bond. If the total duraton of bonds/escrow invoices exceeds 450 blocks, then Phoenix will not allow users to lock the bond (`Cannot add htlc (...) reason=expiry too big`).

View File

@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: RoboSats REST API
version: 0.6.0
version: 0.6.2
x-logo:
url: https://raw.githubusercontent.com/Reckless-Satoshi/robosats/main/frontend/static/assets/images/robosats-0.1.1-banner.png
backgroundColor: '#FFFFFF'
@ -443,14 +443,17 @@ paths:
- `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
valid LN invoice as cleartext PGP message signed (SHA512) with the robot key.
The amount of the invoice should be `invoice_amount` minus the routing
budget whose parts per million should be specified by `routing_budget_ppm`.
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
cleartext PGP message signed (SHA512) 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.
@ -470,9 +473,7 @@ paths:
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.
maker bond, you are returned your 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
@ -631,7 +632,7 @@ paths:
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
be send as cleartext PGP message signed (SHA512) with the robot key
summary: Withdraw reward
tags:
- reward
@ -721,12 +722,13 @@ paths:
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
Make sure you generate your token using cryptographically secure methods.
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)
[here](https://github.com/RoboSats/robosats/blob/main/frontend/src/utils/token.ts)
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.
The PGP key should be an EdDSA ed25519/cert,sign+cv25519/encr key.
`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
@ -737,7 +739,7 @@ paths:
A gpg key can be created by:
```shell
gpg --full-gen-key
gpg --default-new-key-algo "ed25519/cert,sign+cv25519/encr" --full-gen-key
```
it's public key can be exported in ascii armored format with:
@ -1636,7 +1638,7 @@ components:
type: string
maker_status:
type: string
description: Status of the nick - "Active" or "Inactive"
description: Status of the nick - "Active", "Seen Recently" or "Inactive"
price:
type: number
format: double

View File

@ -1539,7 +1539,7 @@ components:
type: string
maker_status:
type: string
description: Status of the nick - "Active" or "Inactive"
description: Status of the nick - "Active", "Seen Recently" or "Inactive"
price:
type: integer
description: Price in order's fiat currency

View File

@ -1712,7 +1712,7 @@ components:
type: string
maker_status:
type: string
description: Status of the nick - "Active" or "Inactive"
description: Status of the nick - "Active", "Seen Recently" or "Inactive"
price:
type: number
format: double

View File

@ -0,0 +1,12 @@
<p>RoboSats is a simple and private app to exchange bitcoin for national currencies. Robosats simplifies the P2P user experience and uses lightning hold invoices to minimize custody and trust requirements. The deterministically generated robot avatars help users stick to best privacy practices.</p>
<p><br><b>Features:</b></p><ul>
<li>Privacy focused: your robot avatar is deterministically generated, no need for registration.</li>
<li>More than 10 languages available and over 60 fiat currencies</li>
<li>Safe: simply lock a lightning hodl invoice and show you are real and committed.</li>
<li>No data collection. Your communication with your peer is PGP encrypted, only you can read it.</li>
<li>Lightning fast: the average sovereign trade finishes in ~ 8 minutes. Faster than a single block confirmation!</li>
<li>Fully collateralized escrow: your peer is always committed and cannot run away with the funds.</li>
<li>Strong incentives system: attempts of cheating are penalized with the slashing of the Sats in the fidelity bond.</li>
<li>Guides and video tutorials available at https://learn.robosats.com/watch/en</li>
</ul>
<p>You can join other cool Robots and get community support at <a href="https://t.me/robosats">our Telegram group</a>.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1 @@
Simple and private app to exchange bitcoin for national currencies.

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.6.0",
"version": "0.6.2",
"description": "",
"main": "index.js",
"scripts": {
@ -41,7 +41,7 @@
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.6.1",
"prettier": "^3.2.5",
"prettier": "^3.3.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.2",
"webpack": "^5.89.0",
@ -57,10 +57,10 @@
"@mui/lab": "^5.0.0-alpha.136",
"@mui/material": "^5.15.14",
"@mui/system": "^5.15.11",
"@mui/x-data-grid": "^7.3.0",
"@mui/x-data-grid": "^7.6.0",
"@mui/x-date-pickers": "^7.2.0",
"@nivo/core": "^0.85.1",
"@nivo/line": "^0.85.1",
"@nivo/core": "^0.86.0",
"@nivo/line": "^0.86.0",
"base-ex": "^0.8.1",
"country-flag-icons": "^1.5.11",
"date-fns": "^2.30.0",

View File

@ -178,7 +178,7 @@ const Onboarding = ({
/>
</Grid>
{slot?.hashId ? (
{slot?.nickname ? (
<Grid item>
<Typography align='center'>{t('Hi! My name is')}</Typography>
<Typography component='h5' variant='h5'>

View File

@ -90,7 +90,7 @@ const RobotProfile = ({
sx={{ width: '100%' }}
>
<Grid item sx={{ height: '2.3em', position: 'relative' }}>
{slot?.hashId ? (
{slot?.nickname ? (
<Typography align='center' component='h5' variant='h5'>
<div
style={{

View File

@ -44,7 +44,7 @@ const RobotPage = (): JSX.Element => {
const token = urlToken ?? garage.currentSlot;
if (token !== undefined && token !== null && page === 'robot') {
setInputToken(token);
if (window.NativeRobosats === undefined || torStatus === '"Done"') {
if (window.NativeRobosats === undefined || torStatus === 'ON' || !settings.useProxy) {
getGenerateRobot(token);
setView('profile');
}
@ -83,7 +83,7 @@ const RobotPage = (): JSX.Element => {
garage.deleteSlot();
};
if (!(window.NativeRobosats === undefined) && !(torStatus === 'DONE' || torStatus === '"Done"')) {
if (settings.useProxy && !(window.NativeRobosats === undefined) && !(torStatus === 'ON')) {
return (
<Paper
elevation={12}

View File

@ -45,7 +45,6 @@ const ClickThroughDataGrid = styled(DataGrid)({
'& .MuiDataGrid-overlayWrapperInner': {
pointerEvents: 'none',
},
...{ headerStyleFix },
});
const premiumColor = function (baseColor: string, accentColor: string, point: number): string {
@ -897,6 +896,11 @@ const BookTable = ({
: orders;
}, [showControls, orders, fav, paymentMethods]);
const loadingPercentage =
((federation.exchange.enabledCoordinators - federation.exchange.loadingCoordinators) /
federation.exchange.enabledCoordinators) *
100;
if (!fullscreen) {
return (
<Paper
@ -908,6 +912,7 @@ const BookTable = ({
}
>
<ClickThroughDataGrid
sx={headerStyleFix}
localeText={localeText}
rowHeight={3.714 * theme.typography.fontSize}
headerHeight={3.25 * theme.typography.fontSize}
@ -928,12 +933,8 @@ const BookTable = ({
setPaymentMethods,
},
loadingOverlay: {
variant: 'determinate',
value:
((federation.exchange.enabledCoordinators -
federation.exchange.loadingCoordinators) /
federation.exchange.enabledCoordinators) *
100,
variant: loadingPercentage === 0 ? 'indeterminate' : 'determinate',
value: loadingPercentage,
},
}}
paginationModel={paginationModel}
@ -949,6 +950,7 @@ const BookTable = ({
<Dialog open={fullscreen} fullScreen={true}>
<Paper style={{ width: '100%', height: '100%', overflow: 'auto' }}>
<ClickThroughDataGrid
sx={headerStyleFix}
localeText={localeText}
rowHeight={3.714 * theme.typography.fontSize}
headerHeight={3.25 * theme.typography.fontSize}

View File

@ -64,7 +64,7 @@ const ProfileDialog = ({ open = false, onClose }: Props): JSX.Element => {
<ListItem className='profileNickname'>
<ListItemText>
<Typography component='h6' variant='h6'>
{garage.getSlot()?.nickname !== undefined && (
{!garage.getSlot()?.nickname && (
<div style={{ position: 'relative', left: '-7px' }}>
<div
style={{

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useAutocomplete from '@mui/base/useAutocomplete';
import { useAutocomplete } from '@mui/base/useAutocomplete';
import { styled } from '@mui/material/styles';
import {
Button,

View File

@ -90,12 +90,6 @@ const MakerForm = ({
const minRangeAmountMultiple = 1.6;
const amountSafeThresholds = [1.03, 0.98];
useEffect(() => {
// Why?
// const slot = garage.getSlot();
// if (slot?.token) void federation.fetchRobot(garage, slot?.token);
}, [garage.currentSlot]);
useEffect(() => {
setCurrencyCode(currencyDict[fav.currency === 0 ? 1 : fav.currency]);
}, [coordinatorUpdatedAt]);

View File

@ -178,7 +178,7 @@ const OrderDetails = ({
: coordinator.info?.taker_fee ?? 0;
const defaultRoutingBudget = 0.001;
const btc_now = order.satoshis_now / 100000000;
const rate = order.amount > 0 ? order.amount / btc_now : Number(order.max_amount) / btc_now;
const rate = Number(order.max_amount ?? order.amount) / btc_now;
if (isBuyer) {
if (order.amount > 0) {
@ -207,7 +207,7 @@ const OrderDetails = ({
amount: amountString,
method: order.payment_method,
});
receive = t('You receive via Lightning {{amount}} Sats (Approx)', {
receive = t('You receive {{amount}} Sats (Approx)', {
amount: sats,
});
} else {

View File

@ -3,8 +3,8 @@ import SmoothImage from 'react-smooth-image';
import { Avatar, Badge, Tooltip } from '@mui/material';
import { SendReceiveIcon } from '../Icons';
import placeholder from './placeholder.json';
import { robohash } from './RobohashGenerator';
import { AppContext, type UseAppStoreType } from '../../contexts/AppContext';
import { roboidentitiesClient } from '../../services/Roboidentities/Web';
interface Props {
shortAlias?: string | undefined;
@ -54,10 +54,9 @@ const RobotAvatar: React.FC<Props> = ({
const className = placeholderType === 'loading' ? 'loadingAvatar' : 'generatingAvatar';
useEffect(() => {
// TODO: HANDLE ANDROID AVATARS TOO (when window.NativeRobosats !== undefined)
if (hashId !== undefined) {
robohash
.generate(hashId, small ? 'small' : 'large')
roboidentitiesClient
.generateRobohash(hashId, small ? 'small' : 'large')
.then((avatar) => {
setAvatarSrc(avatar);
})
@ -78,9 +77,7 @@ const RobotAvatar: React.FC<Props> = ({
);
} else {
setAvatarSrc(
`file:///android_asset/Web.bundle/assets/federation/avatars/${shortAlias}${
small ? ' .small' : ''
}.webp`,
`file:///android_asset/Web.bundle/assets/federation/avatars/${shortAlias}.webp`,
);
}
setTimeout(() => {

View File

@ -95,7 +95,6 @@ const RobotInfo: React.FC<Props> = ({ coordinator, onClose, disabled }: Props) =
(signedInvoice) => {
console.log('Signed message:', signedInvoice);
void coordinator.fetchReward(signedInvoice, garage, slot?.token).then((data) => {
console.log(data);
setBadInvoice(data.bad_invoice ?? '');
setShowRewardsSpinner(false);
setWithdrawn(data.successful_withdrawal);

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { type UseAppStoreType, AppContext } from '../../contexts/AppContext';
import {
@ -28,17 +28,19 @@ import {
QrCode,
} from '@mui/icons-material';
import { systemClient } from '../../services/System';
import { TorIcon } from '../Icons';
import SwapCalls from '@mui/icons-material/SwapCalls';
import { FederationContext, type UseFederationStoreType } from '../../contexts/FederationContext';
import { GarageContext, UseGarageStoreType } from '../../contexts/GarageContext';
interface SettingsFormProps {
dense?: boolean;
}
const SettingsForm = ({ dense = false }: SettingsFormProps): JSX.Element => {
const { fav, setFav, origin, hostUrl, settings, setSettings } =
useContext<UseAppStoreType>(AppContext);
const { fav, setFav, settings, setSettings } = useContext<UseAppStoreType>(AppContext);
const { federation } = useContext<UseFederationStoreType>(FederationContext);
const { garage } = useContext<UseGarageStoreType>(GarageContext);
const theme = useTheme();
const { t } = useTranslation();
const fontSizes = [
@ -226,7 +228,6 @@ const SettingsForm = ({ dense = false }: SettingsFormProps): JSX.Element => {
value={settings.network}
onChange={(e, network) => {
setSettings({ ...settings, network });
void federation.updateUrls(origin, { ...settings, network }, hostUrl);
systemClient.setItem('settings_network', network);
}}
>
@ -238,6 +239,29 @@ const SettingsForm = ({ dense = false }: SettingsFormProps): JSX.Element => {
</ToggleButton>
</ToggleButtonGroup>
</ListItem>
{window.NativeRobosats !== undefined && (
<ListItem>
<ListItemIcon>
<TorIcon />
</ListItemIcon>
<ToggleButtonGroup
exclusive={true}
value={settings.useProxy}
onChange={(_e, useProxy) => {
setSettings({ ...settings, useProxy });
systemClient.setItem('settings_use_proxy', String(useProxy));
}}
>
<ToggleButton value={true} color='primary'>
{t('Build-in')}
</ToggleButton>
<ToggleButton value={false} color='secondary'>
{t('Disabled')}
</ToggleButton>
</ToggleButtonGroup>
</ListItem>
)}
</List>
</Grid>
</Grid>

View File

@ -55,14 +55,14 @@ const TorIndicator = ({
};
const TorConnectionBadge = (): JSX.Element => {
const { torStatus } = useContext<UseAppStoreType>(AppContext);
const { torStatus, settings } = useContext<UseAppStoreType>(AppContext);
const { t } = useTranslation();
if (window?.NativeRobosats == null) {
if (window?.NativeRobosats == null || !settings.useProxy) {
return <></>;
}
if (torStatus === 'NOTINIT') {
if (torStatus === 'OFF' || torStatus === 'STOPPING') {
return (
<TorIndicator
color='primary'
@ -80,7 +80,7 @@ const TorConnectionBadge = (): JSX.Element => {
title={t('Connecting to TOR network')}
/>
);
} else if (torStatus === '"Done"' || torStatus === 'DONE') {
} else if (torStatus === 'ON') {
return <TorIndicator color='success' progress={false} title={t('Connected to TOR network')} />;
} else {
return (

View File

@ -62,7 +62,7 @@ const TorConnectionBadge = (): JSX.Element => {
return <></>;
}
if (torStatus === 'NOTINIT') {
if (torStatus === 'OFF' || torStatus === 'STOPING') {
return (
<TorIndicator
color='primary'
@ -80,7 +80,7 @@ const TorConnectionBadge = (): JSX.Element => {
title={t('Connecting to TOR network')}
/>
);
} else if (torStatus === '"Done"' || torStatus === 'DONE') {
} else if (torStatus === 'ON') {
return <TorIndicator color='success' progress={false} title={t('Connected to TOR network')} />;
} else {
return (

View File

@ -222,6 +222,7 @@ export const SuccessfulPrompt = ({
takerSummary={order.taker_summary}
platformSummary={order.platform_summary}
orderId={order.id}
coordinatorLongAlias={federation.getCoordinator(order.shortAlias)?.longAlias}
/>
</Grid>
) : (

View File

@ -43,6 +43,7 @@ interface Props {
makerHashId: string;
takerHashId: string;
currencyCode: string;
coordinatorLongAlias: string;
makerSummary: TradeRobotSummary;
takerSummary: TradeRobotSummary;
platformSummary: TradeCoordinatorSummary;
@ -54,6 +55,7 @@ const TradeSummary = ({
makerHashId,
takerHashId,
currencyCode,
coordinatorLongAlias,
makerSummary,
takerSummary,
platformSummary,
@ -72,6 +74,7 @@ const TradeSummary = ({
const onClickExport = function (): void {
const summary = {
coordinator: coordinatorLongAlias,
order_id: orderId,
currency: currencyCode,
maker: makerSummary,

View File

@ -37,7 +37,7 @@ export interface SlideDirection {
out: 'left' | 'right' | undefined;
}
export type TorStatus = 'NOTINIT' | 'STARTING' | '"Done"' | 'DONE';
export type TorStatus = 'ON' | 'STARTING' | 'STOPPING' | 'OFF';
export const isNativeRoboSats = !(window.NativeRobosats === undefined);
@ -155,8 +155,8 @@ export interface UseAppStoreType {
export const initialAppContext: UseAppStoreType = {
theme: undefined,
torStatus: 'NOTINIT',
settings: getSettings(),
torStatus: 'STARTING',
settings: new Settings(),
setSettings: () => {},
page: entryPage,
setPage: () => {},
@ -225,7 +225,7 @@ export const AppContextProvider = ({ children }: AppContextProviderProps): JSX.E
() => {
setTorStatus(event?.detail);
},
event?.detail === '"Done"' ? 5000 : 0,
event?.detail === 'ON' ? 5000 : 0,
);
});
}, []);

View File

@ -9,12 +9,13 @@ import React, {
type ReactNode,
} from 'react';
import { type Order, Federation } from '../models';
import { type Order, Federation, Settings } from '../models';
import { federationLottery } from '../utils';
import { AppContext, type UseAppStoreType } from './AppContext';
import { GarageContext, type UseGarageStoreType } from './GarageContext';
import NativeRobosats from '../services/Native';
// Refresh delays (ms) according to Order status
const defaultDelay = 5000;
@ -61,7 +62,7 @@ export interface UseFederationStoreType {
}
export const initialFederationContext: UseFederationStoreType = {
federation: new Federation(),
federation: new Federation('onion', new Settings(), ''),
sortedCoordinators: [],
setDelay: () => {},
currentOrderId: { id: null, shortAlias: null },
@ -79,7 +80,7 @@ export const FederationContextProvider = ({
const { settings, page, origin, hostUrl, open, torStatus } =
useContext<UseAppStoreType>(AppContext);
const { setMaker, garage, setBadOrder } = useContext<UseGarageStoreType>(GarageContext);
const [federation, setFederation] = useState(initialFederationContext.federation);
const [federation] = useState(new Federation(origin, settings, hostUrl));
const sortedCoordinators = useMemo(() => federationLottery(federation), []);
const [coordinatorUpdatedAt, setCoordinatorUpdatedAt] = useState<string>(
new Date().toISOString(),
@ -101,20 +102,23 @@ export const FederationContextProvider = ({
setMaker((maker) => {
return { ...maker, coordinator: sortedCoordinators[0] };
}); // default MakerForm coordinator is decided via sorted lottery
federation.registerHook('onFederationUpdate', () => {
setFederationUpdatedAt(new Date().toISOString());
});
federation.registerHook('onCoordinatorUpdate', () => {
setCoordinatorUpdatedAt(new Date().toISOString());
});
}, []);
useEffect(() => {
// On bitcoin network change we reset book, limits and federation info and fetch everything again
const newFed = initialFederationContext.federation;
newFed.registerHook('onFederationUpdate', () => {
setFederationUpdatedAt(new Date().toISOString());
});
newFed.registerHook('onCoordinatorUpdate', () => {
setCoordinatorUpdatedAt(new Date().toISOString());
});
void newFed.start(origin, settings, hostUrl);
setFederation(newFed);
}, [settings.network, torStatus]);
if (window.NativeRobosats === undefined || torStatus === 'ON' || !settings.useProxy) {
void federation.updateUrl(origin, settings, hostUrl);
void federation.update();
const token = garage.getSlot()?.getRobot()?.token;
if (token) void federation.fetchRobot(garage, token);
}
}, [settings.network, settings.useProxy, torStatus]);
const onOrderReceived = (order: Order): void => {
let newDelay = defaultDelay;
@ -176,15 +180,6 @@ export const FederationContextProvider = ({
if (page === 'offers') void federation.updateBook();
}, [page]);
// use effects to fetchRobots on app start and network change
useEffect(() => {
const slot = garage.getSlot();
const robot = slot?.getRobot();
if (robot && garage.currentSlot && slot?.token && robot.encPrivKey && robot.pubKey) {
void federation.fetchRobot(garage, slot.token);
}
}, [settings.network]);
// use effects to fetchRobots on Profile open
useEffect(() => {
const slot = garage.getSlot();

View File

@ -6,11 +6,11 @@ import {
type Order,
type Garage,
} from '.';
import { roboidentitiesClient } from '../services/Roboidentities/Web';
import { apiClient } from '../services/api';
import { validateTokenEntropy } from '../utils';
import { compareUpdateLimit } from './Limit.model';
import { defaultOrder } from './Order.model';
import { robohash } from '../components/RobotAvatar/RobohashGenerator';
export interface Contact {
nostr?: string | undefined;
@ -97,7 +97,7 @@ function calculateSizeLimit(inputDate: Date): number {
}
export class Coordinator {
constructor(value: any) {
constructor(value: any, origin: Origin, settings: Settings, hostUrl: string) {
const established = new Date(value.established);
this.longAlias = value.longAlias;
this.shortAlias = value.shortAlias;
@ -115,6 +115,8 @@ export class Coordinator {
this.testnetNodesPubkeys = value.testnetNodesPubkeys;
this.url = '';
this.basePath = '';
this.updateUrl(origin, settings, hostUrl);
}
// These properties are loaded from federation.json
@ -143,24 +145,9 @@ export class Coordinator {
public loadingInfo: boolean = false;
public limits: LimitList = {};
public loadingLimits: boolean = false;
public loadingRobot: boolean = true;
public loadingRobot: string | null;
start = async (
origin: Origin,
settings: Settings,
hostUrl: string,
onUpdate: (shortAlias: string) => void = () => {},
): Promise<void> => {
if (this.enabled !== true) return;
void this.updateUrl(settings, origin, hostUrl, onUpdate);
};
updateUrl = async (
settings: Settings,
origin: Origin,
hostUrl: string,
onUpdate: (shortAlias: string) => void = () => {},
): Promise<void> => {
updateUrl = (origin: Origin, settings: Settings, hostUrl: string): void => {
if (settings.selfhostedClient && this.shortAlias !== 'local') {
this.url = hostUrl;
this.basePath = `/${settings.network}/${this.shortAlias}`;
@ -168,9 +155,6 @@ export class Coordinator {
this.url = String(this[settings.network][origin]);
this.basePath = '';
}
void this.update(() => {
onUpdate(this.shortAlias);
});
};
update = async (onUpdate: (shortAlias: string) => void = () => {}): Promise<void> => {
@ -191,7 +175,7 @@ export class Coordinator {
generateAllMakerAvatars = async (data: [PublicOrder]): Promise<void> => {
for (const order of data) {
void robohash.generate(order.maker_hash_id, 'small');
roboidentitiesClient.generateRobohash(order.maker_hash_id, 'small');
}
};
@ -201,6 +185,7 @@ export class Coordinator {
if (this.loadingBook) return;
this.loadingBook = true;
this.book = [];
apiClient
.get(this.url, `${this.basePath}/api/book/`)
@ -313,7 +298,7 @@ export class Coordinator {
};
fetchRobot = async (garage: Garage, token: string): Promise<Robot | null> => {
if (!this.enabled || !token) return null;
if (!this.enabled || !token || this.loadingRobot === token) return null;
const robot = garage?.getSlot(token)?.getRobot() ?? null;
const authHeaders = robot?.getAuthHeaders();
@ -324,6 +309,8 @@ export class Coordinator {
if (!hasEnoughEntropy) return null;
this.loadingRobot = token;
garage.updateRobot(token, this.shortAlias, { loading: true });
const newAttributes = await apiClient
@ -346,7 +333,8 @@ export class Coordinator {
})
.catch((e) => {
console.log(e);
});
})
.finally(() => (this.loadingRobot = null));
garage.updateRobot(token, this.shortAlias, {
...newAttributes,
@ -370,7 +358,6 @@ export class Coordinator {
return await apiClient
.get(this.url, `${this.basePath}/api/order/?order_id=${orderId}`, authHeaders)
.then((data) => {
console.log('data', data);
const order: Order = {
...defaultOrder,
...data,

View File

@ -14,14 +14,14 @@ import { updateExchangeInfo } from './Exchange.model';
type FederationHooks = 'onCoordinatorUpdate' | 'onFederationUpdate';
export class Federation {
constructor() {
constructor(origin: Origin, settings: Settings, hostUrl: string) {
this.coordinators = Object.entries(defaultFederation).reduce(
(acc: Record<string, Coordinator>, [key, value]: [string, any]) => {
if (getHost() !== '127.0.0.1:8000' && key === 'local') {
// Do not add `Local Dev` unless it is running on localhost
return acc;
} else {
acc[key] = new Coordinator(value);
acc[key] = new Coordinator(value, origin, settings, hostUrl);
return acc;
}
},
@ -36,7 +36,16 @@ export class Federation {
onCoordinatorUpdate: [],
onFederationUpdate: [],
};
this.loading = true;
this.exchange.loadingCoordinators = Object.keys(this.coordinators).length;
const host = getHost();
const url = `${window.location.protocol}//${host}`;
const tesnetHost = Object.values(this.coordinators).find((coor) => {
return Object.values(coor.testnet).includes(url);
});
if (tesnetHost) settings.network = 'testnet';
}
public coordinators: Record<string, Coordinator>;
@ -69,38 +78,10 @@ export class Federation {
this.triggerHook('onFederationUpdate');
};
// Setup
start = async (origin: Origin, settings: Settings, hostUrl: string): Promise<void> => {
const onCoordinatorStarted = (): void => {
this.exchange.onlineCoordinators = this.exchange.onlineCoordinators + 1;
this.onCoordinatorSaved();
};
this.loading = true;
this.exchange.loadingCoordinators = Object.keys(this.coordinators).length;
const host = getHost();
const url = `${window.location.protocol}//${host}`;
const tesnetHost = Object.values(this.coordinators).find((coor) => {
return Object.values(coor.testnet).includes(url);
});
if (tesnetHost) settings.network = 'testnet';
updateUrl = async (origin: Origin, settings: Settings, hostUrl: string): Promise<void> => {
for (const coor of Object.values(this.coordinators)) {
if (coor.enabled) {
await coor.start(origin, settings, hostUrl, onCoordinatorStarted);
}
coor.updateUrl(origin, settings, hostUrl);
}
this.updateEnabledCoordinators();
};
// On Testnet/Mainnet change
updateUrls = async (origin: Origin, settings: Settings, hostUrl: string): Promise<void> => {
this.loading = true;
for (const coor of Object.values(this.coordinators)) {
await coor.updateUrl(settings, origin, hostUrl);
}
this.loading = false;
};
update = async (): Promise<void> => {
@ -115,9 +96,12 @@ export class Federation {
lifetime_volume: 0,
version: { major: 0, minor: 0, patch: 0 },
};
this.exchange.onlineCoordinators = 0;
this.exchange.loadingCoordinators = Object.keys(this.coordinators).length;
this.updateEnabledCoordinators();
for (const coor of Object.values(this.coordinators)) {
await coor.update(() => {
coor.update(() => {
this.exchange.onlineCoordinators = this.exchange.onlineCoordinators + 1;
this.onCoordinatorSaved();
});
}
@ -125,10 +109,11 @@ export class Federation {
updateBook = async (): Promise<void> => {
this.loading = true;
this.book = [];
this.triggerHook('onCoordinatorUpdate');
this.exchange.loadingCoordinators = Object.keys(this.coordinators).length;
for (const coor of Object.values(this.coordinators)) {
await coor.updateBook(() => {
coor.updateBook(() => {
this.onCoordinatorSaved();
});
}

View File

@ -59,7 +59,9 @@ class Garage {
const rawSlots = JSON.parse(slotsDump);
Object.values(rawSlots).forEach((rawSlot: Record<any, any>) => {
if (rawSlot?.token) {
this.slots[rawSlot.token] = new Slot(rawSlot.token, Object.keys(rawSlot.robots), {});
this.slots[rawSlot.token] = new Slot(rawSlot.token, Object.keys(rawSlot.robots), {}, () =>
this.triggerHook('onRobotUpdate'),
);
Object.keys(rawSlot.robots).forEach((shortAlias) => {
const rawRobot = rawSlot.robots[shortAlias];
@ -113,9 +115,10 @@ class Garage {
if (!token || !shortAliases) return;
if (this.getSlot(token) === null) {
this.slots[token] = new Slot(token, shortAliases, attributes);
this.slots[token] = new Slot(token, shortAliases, attributes, () =>
this.triggerHook('onRobotUpdate'),
);
this.save();
this.triggerHook('onRobotUpdate');
}
};

View File

@ -1,5 +1,6 @@
import i18n from '../i18n/Web';
import { systemClient } from '../services/System';
import { apiClient } from '../services/api';
import { getHost } from '../utils';
export type Language =
@ -42,8 +43,13 @@ class BaseSettings {
: i18n.resolvedLanguage.substring(0, 2);
const networkCookie = systemClient.getItem('settings_network');
this.network = networkCookie !== '' ? networkCookie : 'mainnet';
this.network = networkCookie && networkCookie !== '' ? networkCookie : 'mainnet';
this.host = getHost();
const useProxy = systemClient.getItem('settings_use_proxy');
this.useProxy = window.NativeRobosats !== undefined && useProxy !== 'false';
apiClient.useProxy = this.useProxy;
}
public frontend: 'basic' | 'pro' = 'basic';
@ -56,6 +62,7 @@ class BaseSettings {
public host?: string;
public unsafeClient: boolean = false;
public selfhostedClient: boolean = false;
public useProxy: boolean;
}
export default BaseSettings;

View File

@ -1,17 +1,24 @@
import { sha256 } from 'js-sha256';
import { Robot, type Order } from '.';
import { robohash } from '../components/RobotAvatar/RobohashGenerator';
import { generate_roboname } from 'robo-identities-wasm';
import { roboidentitiesClient } from '../services/Roboidentities/Web';
class Slot {
constructor(token: string, shortAliases: string[], robotAttributes: Record<any, any>) {
constructor(
token: string,
shortAliases: string[],
robotAttributes: Record<any, any>,
onRobotUpdate: () => void,
) {
this.token = token;
this.hashId = sha256(sha256(this.token));
this.nickname = generate_roboname(this.hashId);
// trigger RoboHash avatar generation in webworker and store in RoboHash class cache.
void robohash.generate(this.hashId, 'small');
void robohash.generate(this.hashId, 'large');
this.nickname = null;
roboidentitiesClient.generateRoboname(this.hashId).then((nickname) => {
this.nickname = nickname;
onRobotUpdate();
});
roboidentitiesClient.generateRobohash(this.hashId, 'small');
roboidentitiesClient.generateRobohash(this.hashId, 'large');
this.robots = shortAliases.reduce((acc: Record<string, Robot>, shortAlias: string) => {
acc[shortAlias] = new Robot(robotAttributes);
@ -22,6 +29,7 @@ class Slot {
this.activeShortAlias = null;
this.lastShortAlias = null;
this.copiedToken = false;
onRobotUpdate();
}
token: string | null;

View File

@ -15,7 +15,7 @@ export interface ReactNativeWebView {
export interface NativeWebViewMessageHttp {
id?: number;
category: 'http';
type: 'post' | 'get' | 'put' | 'delete' | 'xhr';
type: 'post' | 'get' | 'put' | 'delete';
path: string;
baseUrl: string;
headers?: object;
@ -30,7 +30,19 @@ export interface NativeWebViewMessageSystem {
detail?: string;
}
export declare type NativeWebViewMessage = NativeWebViewMessageHttp | NativeWebViewMessageSystem;
export interface NativeWebViewMessageRoboidentities {
id?: number;
category: 'roboidentities';
type: 'roboname' | 'robohash';
string?: string;
size?: string;
}
export declare type NativeWebViewMessage =
| NativeWebViewMessageHttp
| NativeWebViewMessageSystem
| NativeWebViewMessageRoboidentities
| NA;
export interface NativeRobosatsPromise {
resolve: (value: object | PromiseLike<object>) => void;

View File

@ -0,0 +1,4 @@
import RoboidentitiesClientNativeClient from './RoboidentitiesNativeClient';
import { RoboidentitiesClient } from './type';
export const roboidentitiesClient: RoboidentitiesClient = new RoboidentitiesClientNativeClient();

View File

@ -0,0 +1,42 @@
import { type RoboidentitiesClient } from '../type';
class RoboidentitiesNativeClient implements RoboidentitiesClient {
private robonames: Record<string, string> = {};
private robohashes: Record<string, string> = {};
public generateRoboname: (initialString: string) => Promise<string> = async (initialString) => {
if (this.robonames[initialString]) {
return this.robonames[initialString];
} else {
const response = await window.NativeRobosats?.postMessage({
category: 'roboidentities',
type: 'roboname',
detail: initialString,
});
const result = response ? Object.values(response)[0] : '';
this.robonames[initialString] = result;
return result;
}
};
public generateRobohash: (initialString: string, size: 'small' | 'large') => Promise<string> =
async (initialString, size) => {
const key = `${initialString};${size === 'small' ? 80 : 256}`;
if (this.robohashes[key]) {
return this.robohashes[key];
} else {
const response = await window.NativeRobosats?.postMessage({
category: 'roboidentities',
type: 'robohash',
detail: key,
});
const result = response ? Object.values(response)[0] : '';
const image = `data:image/png;base64,${result}`;
this.robohashes[key] = image;
return image;
}
};
}
export default RoboidentitiesNativeClient;

View File

@ -81,7 +81,7 @@ class RoboGenerator {
hash,
size,
) => {
const cacheKey = `${size}px;${hash}`;
const cacheKey = `${hash};${size}`;
if (this.assetsCache[cacheKey]) {
return this.assetsCache[cacheKey];
} else {

View File

@ -0,0 +1,18 @@
import { type RoboidentitiesClient } from '../type';
import { generate_roboname } from 'robo-identities-wasm';
import { robohash } from './RobohashGenerator';
class RoboidentitiesClientWebClient implements RoboidentitiesClient {
public generateRoboname: (initialString: string) => Promise<string> = async (initialString) => {
return new Promise<string>(async (resolve, _reject) => {
resolve(generate_roboname(initialString));
});
};
public generateRobohash: (initialString: string, size: 'small' | 'large') => Promise<string> =
async (initialString, size) => {
return robohash.generate(initialString, size);
};
}
export default RoboidentitiesClientWebClient;

View File

@ -0,0 +1,4 @@
import RoboidentitiesClientWebClient from './RoboidentitiesWebClient';
import { RoboidentitiesClient } from './type';
export const roboidentitiesClient: RoboidentitiesClient = new RoboidentitiesClientWebClient();

View File

@ -0,0 +1,4 @@
export interface RoboidentitiesClient {
generateRoboname: (initialString: string) => Promise<string>;
generateRobohash: (initialString: string, size: 'small' | 'large') => Promise<string>;
}

View File

@ -28,7 +28,7 @@ class SystemNativeClient implements SystemClient {
};
public setCookie: (key: string, value: string) => void = (key, value) => {
delete window.NativeRobosats?.cookies[key];
window.NativeRobosats?.loadCookie({ key, value });
void window.NativeRobosats?.postMessage({
category: 'system',
type: 'setCookie',

View File

@ -1,8 +1,12 @@
import { type ApiClient, type Auth } from '..';
import { systemClient } from '../../System';
import ApiWebClient from '../ApiWebClient';
class ApiNativeClient implements ApiClient {
private assetsCache: Record<string, string> = {};
public useProxy = true;
private webClient: ApiClient = new ApiWebClient();
private readonly assetsPromises = new Map<string, Promise<string | undefined>>();
private readonly getHeaders: (auth?: Auth) => HeadersInit = (auth) => {
@ -51,6 +55,7 @@ class ApiNativeClient implements ApiClient {
public delete: (baseUrl: string, path: string, auth?: Auth) => Promise<object | undefined> =
async (baseUrl, path, auth) => {
if (!this.proxy) this.webClient.delete(baseUrl, path, auth);
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'delete',
@ -66,6 +71,7 @@ class ApiNativeClient implements ApiClient {
body: object,
auth?: Auth,
) => Promise<object | undefined> = async (baseUrl, path, body, auth) => {
if (!this.proxy) this.webClient.post(baseUrl, path, body, auth);
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'post',
@ -81,6 +87,7 @@ class ApiNativeClient implements ApiClient {
path,
auth,
) => {
if (!this.proxy) this.webClient.get(baseUrl, path, auth);
return await window.NativeRobosats?.postMessage({
category: 'http',
type: 'get',
@ -89,41 +96,6 @@ class ApiNativeClient implements ApiClient {
headers: this.getHeaders(auth),
}).then(this.parseResponse);
};
public fileImageUrl: (baseUrl: string, path: string) => Promise<string | undefined> = async (
baseUrl,
path,
) => {
if (path === '') {
return await Promise.resolve('');
}
if (this.assetsCache[path] != null) {
return await Promise.resolve(this.assetsCache[path]);
} else if (this.assetsPromises.has(path)) {
return await this.assetsPromises.get(path);
}
this.assetsPromises.set(
path,
new Promise<string>((resolve, reject) => {
window.NativeRobosats?.postMessage({
category: 'http',
type: 'xhr',
baseUrl,
path,
})
.then((fileB64: { b64Data: string }) => {
this.assetsCache[path] = `data:image/png;base64,${fileB64.b64Data}`;
this.assetsPromises.delete(path);
resolve(this.assetsCache[path]);
})
.catch(reject);
}),
);
return await this.assetsPromises.get(path);
};
}
export default ApiNativeClient;

View File

@ -1,6 +1,8 @@
import { type ApiClient, type Auth } from '..';
class ApiWebClient implements ApiClient {
public useProxy = false;
private readonly getHeaders: (auth?: Auth) => HeadersInit = (auth) => {
let headers = {
'Content-Type': 'application/json',

View File

@ -7,11 +7,11 @@ export interface Auth {
}
export interface ApiClient {
useProxy: boolean;
post: (baseUrl: string, path: string, body: object, auth?: Auth) => Promise<object | undefined>;
put: (baseUrl: string, path: string, body: object, auth?: Auth) => Promise<object | undefined>;
get: (baseUrl: string, path: string, auth?: Auth) => Promise<object | undefined>;
delete: (baseUrl: string, path: string, auth?: Auth) => Promise<object | undefined>;
fileImageUrl?: (baseUrl: string, path: string) => Promise<string | undefined>;
}
export const apiClient: ApiClient =

View File

@ -45,7 +45,6 @@ export default function federationLottery(federation: Federation): string[] {
// federation[shortAlias] = { badges:{ donatesToDevFund }};
// }
// console.log(federation)
// return federation;
// }
@ -58,5 +57,4 @@ export default function federationLottery(federation: Federation): string[] {
// results.push(rankedCoordinators);
// }
// console.log(results)
// }

View File

@ -25,9 +25,9 @@
"hasLargeLimits": true
},
"policies": {
"Policy Name 1": "Experimental coordinator used for development. Use at your own risk.",
"Privacy Policy": "...",
"Data Policy": "..."
"Experimental": "Experimental coordinator used for development. Use at your own risk.",
"Dispute Policy": "Evidence in Disputes: In the event of a dispute, users will be asked to provide transaction-related evidence. This could include transaction IDs, screenshots of payment confirmations, or other pertinent transaction records. Personal information or unrelated transaction details should be redacted to maintain privacy.",
"Non eligible countries": "USA citizens and residents are not allowed to use the Experimental coordinator. F2F transactions are explicitly blocked at creation time for US locations. If a US citizen or resident violates this rule and is found out to be using the Experimental coordinator during a dispute process, they will be denied further service and the dispute mediation will be terminated."
},
"mainnet": {
"onion": "http://robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion",

View File

@ -464,8 +464,8 @@
"The order has expired": "L'ordre ha expirat",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Encara no pots prendre cap ordre! Espera {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "Tu reps via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "Reps via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "Tu reps via Lightning {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "Tu envies via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "Envies via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Prima: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "La teva última ordre #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Fosc",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Clar",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "Nabídka vypršela",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Nabídku nemůžeš zatím příjmout! Počkej {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Přirážka: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Tvá poslední nabídka #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "Die Order ist abgelaufen",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Du kannst noch keine Order annehmen! Warte {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Aufschlag: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Deine letzte Order #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "The order has expired",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Your last order #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "La orden ha expirado",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "¡No puedes tomar una orden aún! Espera {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Prima: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Tu última orden #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Oscuro",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Claro",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "Eskaera iraungi da",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Oraindik ezin duzu eskaerarik hartu! Itxaron{{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Prima: %{{premium}}",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Zure azken eskaera #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "L'ordre a expiré",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Vous ne pouvez pas encore prendre un ordre! Attendez {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "Vous recevez via Lightning {{amount}} Sats (environ)",
"You receive via {{method}} {{amount}}": "Vous recevez via {{méthode}} {{montant}}",
"You receive {{amount}} Sats (Approx)": "Vous recevez via Lightning {{amount}} Sats (environ)",
"You send via Lightning {{amount}} Sats (Approx)": "Vous envoyez via Lightning {{amount}} Sats (environ)",
"You send via {{method}} {{amount}}": "Vous envoyez via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Prime: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Votre dernière commande #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Sombre",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "L'ordine è scaduto",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "La posizione appuntata è approssimativa. La posizione esatta del luogo dell'incontro deve essere indicata nella chat crittografata.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Non puoi ancora accettare un ordine! Aspetta {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "Ricevi {{amount}} Sats via Lightning (approssimativo)",
"You receive via {{method}} {{amount}}": "Ricevi {{amount}} via {{method}}",
"You receive {{amount}} Sats (Approx)": "Ricevi {{amount}} Sats via Lightning (approssimativo)",
"You send via Lightning {{amount}} Sats (Approx)": "Invii {{amount}} Sats via Lightning (approssimativo)",
"You send via {{method}} {{amount}}": "Invii {{amount}} via {{method}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Premio: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Il tuo ultimo ordine #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Scuro",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Chiaro",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "注文は期限切れになりました",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "まだ注文を受け取ることはできません!{{timeMin}}分{{timeSec}}秒待ってください",
"You receive via Lightning {{amount}} Sats (Approx)": "ライトニングで{{amount}} Satsを受け取ります",
"You receive via {{method}} {{amount}}": "{{method}}で{{amount}}を受け取ります",
"You receive {{amount}} Sats (Approx)": "ライトニングで{{amount}} Satsを受け取ります",
"You send via Lightning {{amount}} Sats (Approx)": "ライトニングで{{amount}} Satsを送信します",
"You send via {{method}} {{amount}}": "{{method}}で{{amount}}を送信します",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - プレミアム: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "前回のオーダー #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "ダーク",
"Disabled": "Disabled",
"Fiat": "フィアット",
"Light": "ライト",
"Mainnet": "メインネット",

View File

@ -464,8 +464,8 @@
"The order has expired": "Zamówienie wygasło",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Nie możesz jeszcze przyjąć zamówienia! Czekać {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Premia: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Your last order #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "A ordem expirou",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Você ainda não pode fazer um pedido! Espere {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Prêmio: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Sua última ordem #{{orderID}}",
"finished order": "ordem finalizada",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "Срок действия ордера истёк",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "Закрепленное местоположение является приблизительным. Точное местоположение места встречи необходимо сообщить в зашифрованном чате.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Вы ещё не можете взять ордер! Подождите {{timeMin}}м {{timeSec}}с",
"You receive via Lightning {{amount}} Sats (Approx)": "Вы получаете через Lightning {{amount}} Сатоши (приблизительно)",
"You receive via {{method}} {{amount}}": "Вы получаете через {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "Вы получаете через Lightning {{amount}} Сатоши (приблизительно)",
"You send via Lightning {{amount}} Sats (Approx)": "Вы отправляете через Lightning {{amount}} Сатоши (приблизительно)",
"You send via {{method}} {{amount}}": "Вы отправляете через {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Наценка: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Ваш последний ордер #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Темный",
"Disabled": "Disabled",
"Fiat": "Фиат",
"Light": "Светлый",
"Mainnet": "Основная сеть",

View File

@ -464,8 +464,8 @@
"The order has expired": "Ordern har förfallit",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Du kan inte ta en order ännu! Vänta {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Din senaste order #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "Agizo limekwisha muda",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "Hauwezi kuchukua agizo bado! Subiri {{timeMin}}m {{timeSec}}s",
"You receive via Lightning {{amount}} Sats (Approx)": "Utapokea kupitia Lightning {{amount}} Sats (Takriban)",
"You receive via {{method}} {{amount}}": "Utapokea kupitia {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "Utapokea kupitia Lightning {{amount}} Sats (Takriban)",
"You send via Lightning {{amount}} Sats (Approx)": "Utatuma kupitia Lightning {{amount}} Sats (Takriban)",
"You send via {{method}} {{amount}}": "Utatuma kupitia {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "Amri yako ya mwisho #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Giza",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Nuru",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "รายการหมดอายุแล้ว",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "คุณยังไม่สามารถดำเนินรายการได้! รออีก {{timeMin}} นาที {{timeSec}} วินาที",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "You receive via {{method}} {{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "You send via {{method}} {{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - ค่าพรีเมี่ยม: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "รายการล่าสุดของคุณ #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "Dark",
"Disabled": "Disabled",
"Fiat": "Fiat",
"Light": "Light",
"Mainnet": "Mainnet",

View File

@ -464,8 +464,8 @@
"The order has expired": "订单已到期",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "你暂时还不能吃单!请等{{timeMin}}分 {{timeSec}}秒",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "你通过{{method}}接收{{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "你通过{{method}}发送{{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - 溢价: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "你的上一笔交易 #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "深色",
"Disabled": "Disabled",
"Fiat": "法币",
"Light": "浅色",
"Mainnet": "主网",

View File

@ -464,8 +464,8 @@
"The order has expired": "訂單已到期",
"The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.": "The pinned location is approximate. The exact location for the meeting place must be exchanged in the encrypted chat.",
"You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s": "你暫時還不能吃單!請等{{timeMin}}分 {{timeSec}}秒",
"You receive via Lightning {{amount}} Sats (Approx)": "You receive via Lightning {{amount}} Sats (Approx)",
"You receive via {{method}} {{amount}}": "你通過{{method}}接收{{amount}}",
"You receive {{amount}} Sats (Approx)": "You receive {{amount}} Sats (Approx)",
"You send via Lightning {{amount}} Sats (Approx)": "You send via Lightning {{amount}} Sats (Approx)",
"You send via {{method}} {{amount}}": "你通過{{method}}發送{{amount}}",
"{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%": "{{price}} {{currencyCode}}/BTC - 溢價: {{premium}}%",
@ -489,7 +489,9 @@
"Your last order #{{orderID}}": "你的上一筆交易 #{{orderID}}",
"finished order": "finished order",
"#43": "Phrases in components/SettingsForm/index.tsx",
"Build-in": "Build-in",
"Dark": "深色",
"Disabled": "Disabled",
"Fiat": "法幣",
"Light": "淺色",
"Mainnet": "主網",

View File

@ -56,6 +56,15 @@ const configMobile: Configuration = {
async: true,
},
},
{
test: path.resolve(__dirname, 'src/services/Roboidentities/Web.ts'),
loader: 'file-replace-loader',
options: {
condition: 'if-replacement-exists',
replacement: path.resolve(__dirname, 'src/services/Roboidentities/Native.ts'),
async: true,
},
},
{
test: path.resolve(__dirname, 'src/components/RobotAvatar/placeholder.json'),
loader: 'file-replace-loader',
@ -81,6 +90,10 @@ const configMobile: Configuration = {
from: path.resolve(__dirname, 'static/assets/sounds'),
to: path.resolve(__dirname, '../mobile/html/Web.bundle/assets/sounds'),
},
{
from: path.resolve(__dirname, 'static/federation'),
to: path.resolve(__dirname, '../mobile/html/Web.bundle/assets/federation'),
},
],
}),
],

View File

@ -1,23 +1,45 @@
import React, { useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { SafeAreaView, Text, Platform, Appearance } from 'react-native';
import { SafeAreaView, Text, Platform, Appearance, DeviceEventEmitter } from 'react-native';
import TorClient from './services/Tor';
import Clipboard from '@react-native-clipboard/clipboard';
import NetInfo from '@react-native-community/netinfo';
import EncryptedStorage from 'react-native-encrypted-storage';
import { name as app_name, version as app_version } from './package.json';
import TorModule from './native/TorModule';
import RoboIdentitiesModule from './native/RoboIdentitiesModule';
const backgroundColors = {
light: 'white',
dark: 'black',
};
export type TorStatus = 'ON' | 'STARTING' | 'STOPPING' | 'OFF';
const App = () => {
const colorScheme = Appearance.getColorScheme() ?? 'light';
const torClient = new TorClient();
const webViewRef = useRef<WebView>();
const uri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/index.html';
useEffect(() => {
TorModule.start();
DeviceEventEmitter.addListener('TorStatus', (payload) => {
if (payload.torStatus === 'OFF') TorModule.restart();
injectMessage({
category: 'system',
type: 'torStatus',
detail: payload.torStatus,
});
});
}, []);
useEffect(() => {
const interval = setInterval(() => {
TorModule.getTorStatus();
}, 2000);
return () => clearInterval(interval);
}, []);
const injectMessageResolve = (id: string, data?: object) => {
const json = JSON.stringify(data || {});
webViewRef.current?.injectJavaScript(
@ -49,6 +71,7 @@ const App = () => {
loadCookie('settings_mode');
loadCookie('settings_light_qr');
loadCookie('settings_network');
loadCookie('settings_use_proxy');
loadCookie('garage_slots').then(() => injectMessageResolve(responseId));
};
@ -72,7 +95,7 @@ const App = () => {
const onMessage = async (event: WebViewMessageEvent) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.category === 'http') {
sendTorStatus();
TorModule.getTorStatus();
if (data.type === 'get') {
torClient
.get(data.baseUrl, data.path, data.headers)
@ -80,7 +103,7 @@ const App = () => {
injectMessageResolve(data.id, response);
})
.catch((e) => onCatch(data.id, e))
.finally(sendTorStatus);
.finally(TorModule.getTorStatus);
} else if (data.type === 'post') {
torClient
.post(data.baseUrl, data.path, data.body, data.headers)
@ -88,7 +111,7 @@ const App = () => {
injectMessageResolve(data.id, response);
})
.catch((e) => onCatch(data.id, e))
.finally(sendTorStatus);
.finally(TorModule.getTorStatus);
} else if (data.type === 'delete') {
torClient
.delete(data.baseUrl, data.path, data.headers)
@ -96,15 +119,7 @@ const App = () => {
injectMessageResolve(data.id, response);
})
.catch((e) => onCatch(data.id, e))
.finally(sendTorStatus);
} else if (data.type === 'xhr') {
torClient
.request(data.baseUrl, data.path)
.then((response: object) => {
injectMessageResolve(data.id, response);
})
.catch((e) => onCatch(data.id, e))
.finally(sendTorStatus);
.finally(TorModule.getTorStatus);
}
} else if (data.category === 'system') {
if (data.type === 'init') {
@ -116,6 +131,14 @@ const App = () => {
} else if (data.type === 'deleteCookie') {
EncryptedStorage.removeItem(data.key);
}
} else if (data.category === 'roboidentities') {
if (data.type === 'roboname') {
const roboname = await RoboIdentitiesModule.generateRoboname(data.detail);
injectMessageResolve(data.id, { roboname });
} else if (data.type === 'robohash') {
const robohash = await RoboIdentitiesModule.generateRobohash(data.detail);
injectMessageResolve(data.id, { robohash });
}
}
};
@ -132,23 +155,6 @@ const App = () => {
} catch (error) {}
};
const sendTorStatus = async (event?: any) => {
NetInfo.fetch().then(async (state) => {
let daemonStatus = 'ERROR';
if (state.isInternetReachable) {
try {
daemonStatus = await torClient.daemon.getDaemonStatus();
} catch {}
}
injectMessage({
category: 'system',
type: 'torStatus',
detail: daemonStatus,
});
});
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: backgroundColors[colorScheme] }}>
<WebView

View File

@ -151,8 +151,8 @@ android {
applicationId "com.robosats"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "0.6.0-alpha"
versionCode 2
versionName "0.6.2-alpha"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {
@ -238,6 +238,12 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
release {
storeFile file("../../../keystore.jks")
keyAlias System.getenv("KEY_ALIAS")
storePassword System.getenv("KEY_STORE_PASS")
keyPassword System.getenv("KEY_PASS")
}
}
buildTypes {
debug {
@ -246,7 +252,7 @@ android {
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
@ -271,6 +277,14 @@ android {
packagingOptions {
// Make sure libjsc.so does not packed in APK
exclude "**/libjsc.so"
jniLibs.useLegacyPackaging = true
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
}
@ -282,7 +296,9 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation files("../../node_modules/react-native-tor/android/libs/sifir_android.aar")
implementation "io.matthewnelson.kotlin-components:kmp-tor:4.8.6-0-1.4.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2"
if (enableHermes) {
//noinspection GradleDynamicVersion
@ -326,3 +342,5 @@ def isNewArchitectureEnabled() {
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}
apply plugin: 'kotlin-android'

View File

@ -10,7 +10,9 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:extractNativeLibs="true"
>
<activity
android:name=".MainActivity"
android:label="@string/app_name"

View File

@ -1,17 +1,14 @@
package com.robosats;
import android.app.Application;
import android.content.Context;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.config.ReactFeatureFlags;
import com.facebook.soloader.SoLoader;
import android.webkit.WebView;
import com.robosats.newarchitecture.MainApplicationReactNativeHost;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
public class MainApplication extends Application implements ReactApplication {
@ -29,6 +26,8 @@ public class MainApplication extends Application implements ReactApplication {
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new RobosatsPackage());
return packages;
}

View File

@ -0,0 +1,22 @@
package com.robosats;
import android.util.Log;
public class RoboIdentities {
static {
System.loadLibrary("robonames");
System.loadLibrary("robohash");
}
public String generateRoboname(String initial_string) {
return nativeGenerateRoboname(initial_string);
}
public String generateRobohash(String initial_string) {
return nativeGenerateRobohash(initial_string);
}
// Native functions implemented in Rust.
private static native String nativeGenerateRoboname(String initial_string);
private static native String nativeGenerateRobohash(String initial_string);
}

View File

@ -0,0 +1,30 @@
package com.robosats;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.robosats.modules.RoboIdentitiesModule;
import com.robosats.modules.TorModule;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class RobosatsPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new TorModule(reactContext));
modules.add(new RoboIdentitiesModule(reactContext));
return modules;
}
}

View File

@ -0,0 +1,37 @@
package com.robosats.modules;
import android.util.Log;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.robosats.RoboIdentities;
public class RoboIdentitiesModule extends ReactContextBaseJavaModule {
private ReactApplicationContext context;
public RoboIdentitiesModule(ReactApplicationContext reactContext) {
context = reactContext;
}
@Override
public String getName() {
return "RoboIdentitiesModule";
}
@ReactMethod
public void generateRoboname(String initial_string, final Promise promise) {
String roboname = new RoboIdentities().generateRoboname(initial_string);
promise.resolve(roboname);
}
@ReactMethod
public void generateRobohash(String initial_string, final Promise promise) {
String robohash = new RoboIdentities().generateRobohash(initial_string);
promise.resolve(robohash);
}
}

View File

@ -0,0 +1,158 @@
package com.robosats.modules;
import android.util.Log;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.robosats.tor.TorKmpManager;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
public class TorModule extends ReactContextBaseJavaModule {
private TorKmpManager torKmpManager;
private ReactApplicationContext context;
public TorModule(ReactApplicationContext reactContext) {
context = reactContext;
}
@Override
public String getName() {
return "TorModule";
}
@ReactMethod
public void sendRequest(String action, String url, String headers, String body, final Promise promise) throws JSONException {
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout
.readTimeout(30, TimeUnit.SECONDS) // Set read timeout
.proxy(torKmpManager.getProxy()).build();
Request.Builder requestBuilder = new Request.Builder().url(url);
JSONObject headersObject = new JSONObject(headers);
headersObject.keys().forEachRemaining(key -> {
String value = headersObject.optString(key);
requestBuilder.addHeader(key, value);
});
if (Objects.equals(action, "DELETE")) {
requestBuilder.delete();
} else if (Objects.equals(action, "POST")) {
RequestBody requestBody = RequestBody.create(body, MediaType.get("application/json; charset=utf-8"));
requestBuilder.post(requestBody);
} else {
requestBuilder.get();
}
Request request = requestBuilder.build();
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
Log.d("RobosatsError", e.toString());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "{}";
JSONObject headersJson = new JSONObject();
response.headers().names().forEach(name -> {
try {
headersJson.put(name, response.header(name));
} catch (JSONException e) {
throw new RuntimeException(e);
}
});
promise.resolve("{\"json\":" + body + ", \"headers\": " + headersJson +"}");
}
});
}
@ReactMethod
public void getTorStatus() {
String torState = torKmpManager.getTorState().getState().name();
WritableMap payload = Arguments.createMap();
payload.putString("torStatus", torState);
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorStatus", payload);
}
@ReactMethod
public void isConnected() {
String isConnected = String.valueOf(torKmpManager.isConnected());
WritableMap payload = Arguments.createMap();
payload.putString("isConnected", isConnected);
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorIsConnected", payload);
}
@ReactMethod
public void isStarting() {
String isStarting = String.valueOf(torKmpManager.isStarting());
WritableMap payload = Arguments.createMap();
payload.putString("isStarting", isStarting);
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorIsStarting", payload);
}
@ReactMethod
public void stop() {
torKmpManager.getTorOperationManager().stopQuietly();
WritableMap payload = Arguments.createMap();
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorStop", payload);
}
@ReactMethod
public void start() {
torKmpManager = new TorKmpManager(context.getCurrentActivity().getApplication());
torKmpManager.getTorOperationManager().startQuietly();
WritableMap payload = Arguments.createMap();
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorStart", payload);
}
@ReactMethod
public void restart() {
torKmpManager = new TorKmpManager(context.getCurrentActivity().getApplication());
torKmpManager.getTorOperationManager().restartQuietly();
WritableMap payload = Arguments.createMap();
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorRestart", payload);
}
@ReactMethod
public void newIdentity() {
torKmpManager.newIdentity(context.getCurrentActivity().getApplication());
WritableMap payload = Arguments.createMap();
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("TorNewIdentity", payload);
}
}

View File

@ -0,0 +1,8 @@
package com.robosats.tor
enum class EnumTorState {
STARTING,
ON,
STOPPING,
OFF
}

View File

@ -0,0 +1,389 @@
package com.robosats.tor
import android.app.Application
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.matthewnelson.kmp.tor.KmpTorLoaderAndroid
import io.matthewnelson.kmp.tor.TorConfigProviderAndroid
import io.matthewnelson.kmp.tor.common.address.*
import io.matthewnelson.kmp.tor.controller.common.config.TorConfig
import io.matthewnelson.kmp.tor.controller.common.config.TorConfig.Option.*
import io.matthewnelson.kmp.tor.controller.common.config.TorConfig.Setting.*
import io.matthewnelson.kmp.tor.controller.common.control.usecase.TorControlInfoGet
import io.matthewnelson.kmp.tor.controller.common.control.usecase.TorControlSignal
import io.matthewnelson.kmp.tor.controller.common.events.TorEvent
import io.matthewnelson.kmp.tor.manager.TorManager
import io.matthewnelson.kmp.tor.manager.TorServiceConfig
import io.matthewnelson.kmp.tor.manager.common.TorControlManager
import io.matthewnelson.kmp.tor.manager.common.TorOperationManager
import io.matthewnelson.kmp.tor.manager.common.event.TorManagerEvent
import io.matthewnelson.kmp.tor.manager.common.state.isOff
import io.matthewnelson.kmp.tor.manager.common.state.isOn
import io.matthewnelson.kmp.tor.manager.common.state.isStarting
import io.matthewnelson.kmp.tor.manager.common.state.isStopping
import io.matthewnelson.kmp.tor.manager.R
import kotlinx.coroutines.*
import java.net.InetSocketAddress
import java.net.Proxy
class TorKmpManager(application : Application) {
private val TAG = "TorListener"
private val providerAndroid by lazy {
object : TorConfigProviderAndroid(context = application) {
override fun provide(): TorConfig {
return TorConfig.Builder {
// Set multiple ports for all of the things
val dns = Ports.Dns()
put(dns.set(AorDorPort.Value(PortProxy(9252))))
put(dns.set(AorDorPort.Value(PortProxy(9253))))
val socks = Ports.Socks()
put(socks.set(AorDorPort.Value(PortProxy(9254))))
put(socks.set(AorDorPort.Value(PortProxy(9255))))
val http = Ports.HttpTunnel()
put(http.set(AorDorPort.Value(PortProxy(9258))))
put(http.set(AorDorPort.Value(PortProxy(9259))))
val trans = Ports.Trans()
put(trans.set(AorDorPort.Value(PortProxy(9262))))
put(trans.set(AorDorPort.Value(PortProxy(9263))))
// If a port (9263) is already taken (by ^^^^ trans port above)
// this will take its place and "overwrite" the trans port entry
// because port 9263 is taken.
put(socks.set(AorDorPort.Value(PortProxy(9263))))
// Set Flags
socks.setFlags(setOf(
Ports.Socks.Flag.OnionTrafficOnly
)).setIsolationFlags(setOf(
Ports.IsolationFlag.IsolateClientAddr,
)).set(AorDorPort.Value(PortProxy(9264)))
put(socks)
// reset our socks object to defaults
socks.setDefault()
// Not necessary, as if ControlPort is missing it will be
// automatically added for you; but for demonstration purposes...
// put(Ports.Control().set(AorDorPort.Auto))
// Use a UnixSocket instead of TCP for the ControlPort.
//
// A unix domain socket will always be preferred on Android
// if neither Ports.Control or UnixSockets.Control are provided.
put(UnixSockets.Control().set(FileSystemFile(
workDir.builder {
// Put the file in the "data" directory
// so that we avoid any directory permission
// issues.
//
// Note that DataDirectory is automatically added
// for you if it is not present in your provided
// config. If you set a custom Path for it, you
// should use it here.
addSegment(DataDirectory.DEFAULT_NAME)
addSegment(UnixSockets.Control.DEFAULT_NAME)
}
)))
// Use a UnixSocket instead of TCP for the SocksPort.
put(UnixSockets.Socks().set(FileSystemFile(
workDir.builder {
// Put the file in the "data" directory
// so that we avoid any directory permission
// issues.
//
// Note that DataDirectory is automatically added
// for you if it is not present in your provided
// config. If you set a custom Path for it, you
// should use it here.
addSegment(DataDirectory.DEFAULT_NAME)
addSegment(UnixSockets.Socks.DEFAULT_NAME)
}
)))
// For Android, disabling & reducing connection padding is
// advisable to minimize mobile data usage.
put(ConnectionPadding().set(AorTorF.False))
put(ConnectionPaddingReduced().set(TorF.True))
// Tor default is 24h. Reducing to 10 min helps mitigate
// unnecessary mobile data usage.
put(DormantClientTimeout().set(Time.Minutes(10)))
// Tor defaults this setting to false which would mean if
// Tor goes dormant, the next time it is started it will still
// be in the dormant state and will not bootstrap until being
// set to "active". This ensures that if it is a fresh start,
// dormancy will be cancelled automatically.
put(DormantCanceledByStartup().set(TorF.True))
// If planning to use v3 Client Authentication in a persistent
// manner (where private keys are saved to disk via the "Persist"
// flag), this is needed to be set.
put(ClientOnionAuthDir().set(FileSystemDir(
workDir.builder { addSegment(ClientOnionAuthDir.DEFAULT_NAME) }
)))
val hsPath = workDir.builder {
addSegment(HiddenService.DEFAULT_PARENT_DIR_NAME)
addSegment("test_service")
}
// Add Hidden services
put(HiddenService()
.setPorts(ports = setOf(
// Use a unix domain socket to communicate via IPC instead of over TCP
HiddenService.UnixSocket(virtualPort = Port(80), targetUnixSocket = hsPath.builder {
addSegment(HiddenService.UnixSocket.DEFAULT_UNIX_SOCKET_NAME)
}),
))
.setMaxStreams(maxStreams = HiddenService.MaxStreams(value = 2))
.setMaxStreamsCloseCircuit(value = TorF.True)
.set(FileSystemDir(path = hsPath))
)
put(HiddenService()
.setPorts(ports = setOf(
HiddenService.Ports(virtualPort = Port(80), targetPort = Port(1030)), // http
HiddenService.Ports(virtualPort = Port(443), targetPort = Port(1030)) // https
))
.set(FileSystemDir(path =
workDir.builder {
addSegment(HiddenService.DEFAULT_PARENT_DIR_NAME)
addSegment("test_service_2")
}
))
)
}.build()
}
}
}
private val loaderAndroid by lazy {
KmpTorLoaderAndroid(provider = providerAndroid)
}
private val manager: TorManager by lazy {
TorManager.newInstance(application = application, loader = loaderAndroid, requiredEvents = null)
}
// only expose necessary interfaces
val torOperationManager: TorOperationManager get() = manager
val torControlManager: TorControlManager get() = manager
private val listener = TorListener()
val events: LiveData<String> get() = listener.eventLines
private val appScope by lazy {
CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
}
val torStateLiveData: MutableLiveData<TorState> = MutableLiveData()
get() = field
var torState: TorState = TorState()
get() = field
var proxy: Proxy? = null
get() = field
init {
manager.debug(true)
manager.addListener(listener)
listener.addLine(TorServiceConfig.getMetaData(application).toString())
}
fun isConnected(): Boolean {
return manager.state.isOn() && manager.state.bootstrap >= 100
}
fun isStarting(): Boolean {
return manager.state.isStarting() ||
(manager.state.isOn() && manager.state.bootstrap < 100);
}
fun newIdentity(appContext: Application) {
appScope.launch {
val result = manager.signal(TorControlSignal.Signal.NewNym)
result.onSuccess {
if (it !is String) {
listener.addLine(TorControlSignal.NEW_NYM_SUCCESS)
Toast.makeText(appContext, TorControlSignal.NEW_NYM_SUCCESS, Toast.LENGTH_SHORT).show()
return@onSuccess
}
val post: String? = when {
it.startsWith(TorControlSignal.NEW_NYM_RATE_LIMITED) -> {
// Rate limiting NEWNYM request: delaying by 8 second(s)
val seconds: Int? = it.drop(TorControlSignal.NEW_NYM_RATE_LIMITED.length)
.substringBefore(' ')
.toIntOrNull()
if (seconds == null) {
it
} else {
appContext.getString(
R.string.kmp_tor_newnym_rate_limited,
seconds
)
}
}
it == TorControlSignal.NEW_NYM_SUCCESS -> {
appContext.getString(R.string.kmp_tor_newnym_success)
}
else -> {
null
}
}
if (post != null) {
listener.addLine(post)
Toast.makeText(appContext, post, Toast.LENGTH_SHORT).show()
}
}
result.onFailure {
val msg = "Tor identity change failed"
listener.addLine(msg)
Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show()
}
}
}
private inner class TorListener: TorManagerEvent.Listener() {
private val _eventLines: MutableLiveData<String> = MutableLiveData("")
val eventLines: LiveData<String> = _eventLines
private val events: MutableList<String> = ArrayList(50)
fun addLine(line: String) {
synchronized(this) {
if (events.size > 49) {
events.removeAt(0)
}
events.add(line)
//Log.i(TAG, line)
//_eventLines.value = events.joinToString("\n")
_eventLines.postValue(events.joinToString("\n"))
}
}
override fun onEvent(event: TorManagerEvent) {
if (event is TorManagerEvent.State) {
val stateEvent: TorManagerEvent.State = event
val state = stateEvent.torState
torState.progressIndicator = state.bootstrap
val liveTorState = TorState()
liveTorState.progressIndicator = state.bootstrap
if (state.isOn()) {
if (state.bootstrap >= 100) {
torState.state = EnumTorState.ON
liveTorState.state = EnumTorState.ON
} else {
torState.state = EnumTorState.STARTING
liveTorState.state = EnumTorState.STARTING
}
} else if (state.isStarting()) {
torState.state = EnumTorState.STARTING
liveTorState.state = EnumTorState.STARTING
} else if (state.isOff()) {
torState.state = EnumTorState.OFF
liveTorState.state = EnumTorState.OFF
} else if (state.isStopping()) {
torState.state = EnumTorState.STOPPING
liveTorState.state = EnumTorState.STOPPING
}
torStateLiveData.postValue(liveTorState)
}
addLine(event.toString())
super.onEvent(event)
}
override fun onEvent(event: TorEvent.Type.SingleLineEvent, output: String) {
addLine("$event - $output")
super.onEvent(event, output)
}
override fun onEvent(event: TorEvent.Type.MultiLineEvent, output: List<String>) {
addLine("multi-line event: $event. See Logs.")
// these events are many many many lines and should be moved
// off the main thread if ever needed to be dealt with.
val enabled = false
if (enabled) {
appScope.launch(Dispatchers.IO) {
Log.d(TAG, "-------------- multi-line event START: $event --------------")
for (line in output) {
Log.d(TAG, line)
}
Log.d(TAG, "--------------- multi-line event END: $event ---------------")
}
}
super.onEvent(event, output)
}
override fun managerEventError(t: Throwable) {
t.printStackTrace()
}
override fun managerEventAddressInfo(info: TorManagerEvent.AddressInfo) {
if (info.isNull) {
// Tear down HttpClient
} else {
info.socksInfoToProxyAddressOrNull()?.firstOrNull()?.let { proxyAddress ->
@Suppress("UNUSED_VARIABLE")
val socket = InetSocketAddress(proxyAddress.address.value, proxyAddress.port.value)
proxy = Proxy(Proxy.Type.SOCKS, socket)
}
}
}
override fun managerEventStartUpCompleteForTorInstance() {
// Do one-time things after we're bootstrapped
appScope.launch {
torControlManager.onionAddNew(
type = OnionAddress.PrivateKey.Type.ED25519_V3,
hsPorts = setOf(HiddenService.Ports(virtualPort = Port(443))),
flags = null,
maxStreams = null,
).onSuccess { hsEntry ->
addLine(
"New HiddenService: " +
"\n - Address: https://${hsEntry.address.canonicalHostname()}" +
"\n - PrivateKey: ${hsEntry.privateKey}"
)
torControlManager.onionDel(hsEntry.address).onSuccess {
addLine("Aaaaaaaaand it's gone...")
}.onFailure { t ->
t.printStackTrace()
}
}.onFailure { t ->
t.printStackTrace()
}
delay(20_000L)
torControlManager.infoGet(TorControlInfoGet.KeyWord.Uptime()).onSuccess { uptime ->
addLine("Uptime - $uptime")
}.onFailure { t ->
t.printStackTrace()
}
}
}
}
}

View File

@ -0,0 +1,14 @@
package com.robosats.tor
class TorState {
var state : EnumTorState = EnumTorState.OFF
get() = field
set(value) {
field = value
}
var progressIndicator : Int = 0
get() = field
set(value) {
field = value
}
}

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More