Compare commits

..
85 Commits
Author SHA1 Message Date
nobswebdev 55630e33b4 Allow application/x-zip-compressed for digital stock attachments.
Browsers often report ZIP files with this MIME type instead of application/zip.
2026-09-25 20:57:28 +02:00
nobswebdev e057b29cc7 Reject cart and discount mutations while checkout session is active.
Prevent cart cookie changes during payment so nav counts and checkout snapshots stay aligned until cancel or completion.
2026-09-18 13:31:10 +02:00
nobswebdev 226a31b937 Merge pull request 'Slave/blog' (#9) from slave/blog into master
Reviewed-on: http://4f5t7o2vgo4sdaliwax3isyxzfuxvbvkuwk4smfowutrvx2uneu4egyd.onion/nobswebdev/nullcart/pulls/9
2026-09-16 20:15:07 +00:00
nobswebdev 6419439a3d Improve storefront blog index card layout and sizing.
Use blog-specific grid breakpoints so mobile stays single-column while wider viewports grow naturally like the product list.
2026-09-16 21:43:30 +02:00
nobswebdev 762af5c4f3 Add CMS blog admin for managing posts and storefront visibility.
Wire list and detail views with image uploads, draft/live editing, and a blog-enabled toggle backed by the shop settings API.
2026-09-16 20:58:22 +02:00
nobswebdev 17a26b60ed Align CMS products list and detail with shared list-view patterns.
Drop mount-time form clearValidate, add table row-key, unify status tags, and simplify load error handling to match other CMS list views.
2026-09-16 20:42:28 +02:00
nobswebdev 9cd7059928 Expose blog image URLs on admin API and fix storefront card layout.
Map storage keys to public paths in BlogPostsService responses so the CMS can resolve uploads consistently, and wrap blog card thumbnails in a fixed aspect-ratio container.
2026-09-16 19:46:11 +02:00
nobswebdev 9c7b8470d3 Add dedicated blog image upload config separate from product thumbs.
Introduce MULTER_BLOG_IMAGE_* and VITE_BLOG_IMAGE_* env vars with backend multer helper and CMS config wiring for prod builds.
2026-09-16 19:13:23 +02:00
nobswebdev e5eb773404 Use descriptive required-field validation messages in CMS forms.
Replace generic "Required" copy with field-specific messages for product, variant, and digital stock forms.
2026-09-16 16:58:13 +02:00
nobswebdev 0cd7158f7d Align CMS title fallbacks and mutation error handling.
Add blog post untitled helper, use product title helper in order views, and surface API validation messages on product save/delete.
2026-09-16 16:20:52 +02:00
nobswebdev 09ad236cb9 Drop explicit empty alt attributes from storefront-image call sites.
Decorative thumbnails rely on the partial default instead of repeating alt='' at each call site.
2026-09-16 13:57:21 +02:00
nobswebdev a3d1842fb3 Add storefront blog pages with nav, templates, and SEO meta.
Wire blogEnabled into shop render locals and expose public blog index and detail routes when the shop toggle is on.
2026-09-16 13:55:12 +02:00
nobswebdev 73f7ffed00 Expose blog toggle via shop settings API and storefront branding.
Add PATCH /shop-settings/blog-enabled and surface blogEnabled in ShopSettingsView and StorefrontBranding.
2026-09-16 11:48:59 +02:00
nobswebdev abe6208e4e Add blog thumbnail and banner upload endpoints with storage keys.
Store image filenames in DB, resolve paths for cleanup on replace/delete, and reuse product-thumb multer limits.
2026-09-16 11:40:05 +02:00
nobswebdev d38def2302 Add blog post admin CRUD API.
Expose JWT-protected /blog-posts endpoints for draft creation, listing, update, and delete.
2026-09-15 20:22:49 +02:00
nobswebdev 92ccbec545 add blog post title validation config 2026-09-15 20:05:25 +02:00
nobswebdev 40bf16c03e Add blog schema with BlogPost entity and shop blog toggle.
Introduces blog_posts table and blogEnabled on shop_settings for optional storefront blog.
2026-09-15 19:36:30 +02:00
nobswebdev 54ec6edd0d remove reduced motion styling 2026-09-14 15:59:53 +02:00
nobswebdev 8372c30e40 Merge pull request 'Slave/add minimal js' (#8) from slave/add-minimal-js into master
Reviewed-on: #8
2026-09-14 13:21:32 +00:00
nobswebdev 0345fe042e Add copy-to-clipboard for crypto payment amounts and addresses.
Mount storefront JS assets, hide copy controls without JavaScript, and load per-page copy handling on checkout and order views.
2026-09-14 15:20:17 +02:00
nobswebdev a9ffc57608 Add top margin to invoice payment QR code for even spacing. 2026-09-13 23:55:11 +02:00
nobswebdev 49d6b597c2 Lower default Bitcoin confirmation tiers for faster on-chain checkout.
Use 1/2/3/6 confirmations by order value instead of 1/3/5/6, since Bitcoin blocks are much slower than Monero.
2026-09-13 12:52:04 +02:00
nobswebdev 0ab7f21bea Fix monero-wallet-rpc startup on mainnet by omitting --mainnet.
Monero treats mainnet as the default network; only --stagenet and --testnet are valid flags.
2026-09-12 23:01:20 +02:00
nobswebdev 155549a2ec Align CMS select dropdowns with their inputs using fit-input-width.
Remove global el-select popper width caps that caused mismatched dropdown sizing on full-width fields.
2026-09-11 21:27:17 +02:00
nobswebdev f1715f7e7f Switch shop nav from Cart to Checkout during an active checkout session.
Show item quantity on both nav states and highlight checkout while the user is on the payment page.
2026-09-11 20:22:22 +02:00
nobswebdev ffc9a0bbe5 Add system theme preference to the storefront.
Default to system when no theme cookie is set so pages follow OS appearance, and expose Light, Dark, and System in the theme switcher.
2026-09-11 19:15:46 +02:00
nobswebdev 77cd686347 Advertise the onion site on clearnet via Onion-Location header. 2026-09-11 15:26:03 +02:00
nobswebdev 08a0bdcdb8 Include www subdomain in clearnet TLS issuance.
Request apex and www from Let's Encrypt and serve both names in nginx.
2026-09-10 16:50:26 +02:00
nobswebdev 93c132b0df Fix SimpleX branding in shop footer link label. 2026-09-09 16:33:04 +02:00
nobswebdev 771d95d1cd Stack and center the shop header on mobile.
Column-layout logo, nav, and crypto rates with full-width wrapping for many order links.
2026-09-09 16:27:58 +02:00
nobswebdev 254736356b Show storefront feedback as fixed top-center toasts.
Use CSS-only dismiss animations, render the partial at the end of body, and add top margin to cart issue messages.
2026-09-09 16:07:58 +02:00
nobswebdev 738e406725 Rename sf-discount-error to sf-cart-issue for cart validation messages.
The class is used for stock, discount, and cart-total issues—not only discounts.
2026-09-09 15:38:15 +02:00
nobswebdev de4630635d Merge pull request 'restore scroll position after adding to cart' (#7) from slave/storefront-products-index-add-to-cart-ux-improvement into master
Reviewed-on: #7
2026-09-09 11:45:50 +00:00
nobswebdev 6b358011b0 restore scroll position after adding to cart 2026-09-09 13:44:25 +02:00
nobswebdev 8729098f20 Merge pull request 'Require min 1 confirmation in payment tier config.' (#6) from slave/get-rid-of-mempool-conf-tier into master
Reviewed-on: #6
2026-09-08 21:33:26 +00:00
nobswebdev deb5f8c2c3 Require min 1 confirmation in payment tier config.
Disallow minConfirmations: 0 for BTC and XMR tiers at startup validation, update env examples and deploy docs, and drop tx-detected UI copy and legacy tests.
2026-09-08 23:31:23 +02:00
nobswebdev b3e683cd71 Merge pull request 'Slave/btc rbf edge case fix' (#5) from slave/btc-rbf-edge-case-fix into master
Reviewed-on: #5
2026-09-08 17:02:47 +00:00
nobswebdev 4458edb0d1 Prune stale unconfirmed invoice payments during wallet polling.
Refactor processInvoice into upsert and prune helpers so absent 0-conf payments are removed when the wallet no longer reports them.
2026-09-08 18:30:16 +02:00
nobswebdev dcd499664a Filter superseded Bitcoin transfers in Electrum wallet client.
Detect RBF conflicts via shared input outpoints so invoice polling no longer ingests both the original and replacement mempool transactions.
2026-09-08 18:30:06 +02:00
nobswebdev 0dcffb394e Cache storefront images aggressively while keeping CSS revalidatable.
Split static asset mounts by type and centralize paths in storefrontAssetPaths so icon URLs stay aligned with the server.
2026-09-07 14:06:44 +02:00
nobswebdev e6c21e58bb Merge pull request 'Slave/btc integration' (#4) from slave/btc-integration into master
Reviewed-on: #4
2026-09-07 11:11:09 +00:00
nobswebdev 8639176af4 Throw on unsupported payment methods in issueInvoice.
Prevent checkout sessions from being created without an invoice when
the payment method switch has no matching case.
2026-09-07 12:39:31 +02:00
nobswebdev 68503ea01d Add payment method icons to storefront navbar and cart.
Show BTC/XMR icons beside exchange rates and pay buttons.
2026-09-07 12:28:14 +02:00
nobswebdev dd234421a4 Rename cryptoCurrency to paymentLabel in storefront invoice views.
Align invoice view naming with InvoiceExtended and CMS.
2026-09-07 12:28:09 +02:00
nobswebdev 2d8a508898 Fix Monero withdraw form reset after successful withdrawal.
Use resetFields so validation state clears without re-triggering required rules on an empty address.
2026-09-07 02:08:22 +02:00
nobswebdev de52534271 Add Bitcoin wallet withdraw and seed reveal UI in CMS.
Wire admin actions to the backend API with client-side address validation and configurable max fee rate.
2026-09-07 02:08:17 +02:00
nobswebdev 626f2ea4f0 add bitcoin withdraw max fee validation config 2026-09-07 01:35:57 +02:00
nobswebdev 6c894d58ba Add Bitcoin wallet admin withdraw and seed reveal API.
Expose JWT-protected withdraw-all and reveal-seed endpoints with address validation and password verification, matching Monero wallet admin behavior.
2026-09-06 23:28:53 +02:00
nobswebdev 3a72d0521a Add sweepAll to Electrum wallet RPC client.
Consolidate payto, deserialize, and broadcast into a single sweep helper for max-payment withdrawals.
2026-09-06 23:28:47 +02:00
nobswebdev 8d9054b119 pass wallet password to electrum config on backend 2026-09-06 21:08:13 +02:00
nobswebdev c1e28c7545 add fee priority selector to Monero wallet withdraw form.
Expose GUI-style priority options in the CMS and include the chosen priority in withdrawal confirmation.
2026-09-06 18:21:13 +02:00
nobswebdev 83cb437f19 add fee priority to Monero wallet withdrawal API.
Pass sweep_all priority through the withdraw endpoint so admins can choose transaction fee speed.
2026-09-06 18:21:07 +02:00
nobswebdev 564b016f47 use explicit nested routes for settings and wallet CMS tabs. 2026-09-06 16:08:02 +02:00
nobswebdev 82932ce7c8 add Bitcoin wallet status tab in CMS. 2026-09-06 16:07:56 +02:00
nobswebdev 33600d5c1c consolidate confirmation tiers and show Bitcoin tiers in shop settings. 2026-09-06 16:07:49 +02:00
nobswebdev 7640f5d74d generalize CMS order invoice payment panel for multi-crypto invoices. 2026-09-06 16:07:44 +02:00
nobswebdev 35052661f8 consolidate monero wallet sync status into shared WalletSyncStatus type. 2026-09-06 15:27:55 +02:00
nobswebdev 5f1c8bee4b consolidate wallet sync status into shared WalletSyncStatus type. 2026-09-06 15:21:13 +02:00
nobswebdev 9e776df79f expose payment label and multi-crypto amounts in admin order invoice view. 2026-09-06 14:19:37 +02:00
nobswebdev e7c42fcaca add resolveAtomicToCryptoConverter for payment method amount formatting. 2026-09-06 14:19:32 +02:00
nobswebdev 665362eb32 use path-to-regexp named wildcard for shop assets route exclude
Silence Nest 11 LegacyRouteConverter warning by replacing shop/assets/(.*) with shop/assets/{*path}.
2026-09-06 11:40:58 +02:00
nobswebdevandCursor c7ac66a5cd set explicit color-scheme on storefront theme roots
Align native browser controls with the active light or dark theme instead of using light dark on html.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:42:10 +02:00
nobswebdevandCursor 500fe6cf94 use checkout payment method for shipping invoices
Load btc details on admin order queries and issue shipping invoices in the same crypto the customer used at checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:42:05 +02:00
nobswebdevandCursor 917e86b3de load btc invoice details in checkout session and storefront order queries
Ensure bitcoin invoice metadata is available when rendering checkout sessions and customer order pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:42:02 +02:00
nobswebdevandCursor cf92212226 add bitcoin support to storefront invoice view
Refactor invoice view building to share logic across XMR and BTC, including BIP21 QR codes and payment detail validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:41:57 +02:00
nobswebdev ced92ead37 load btc invoice details in checkout and order queries 2026-09-05 22:42:01 +02:00
nobswebdev b728c34357 wire checkout pay to selected payment method 2026-09-05 22:07:02 +02:00
nobswebdev 7222d0c9be show multi-crypto rates in shop nav 2026-09-05 20:50:49 +02:00
nobswebdev 302c3729c4 add multi-crypto payment buttons to cart 2026-09-05 18:55:58 +02:00
nobswebdev 3b6323658e remove crypto rate display from cart 2026-09-05 14:48:01 +02:00
nobswebdev 7b62bc41ad default dev electrum to testnet4 2026-09-04 17:26:32 +02:00
nobswebdev b4618cf3b1 wire bitcoin invoice creation and payment polling 2026-09-04 14:11:54 +02:00
nobswebdev cae5b4fe50 add bitcoin wallet admin status endpoint 2026-09-04 11:59:06 +02:00
nobswebdev 8c0749875e add electrum wallet rpc client 2026-09-04 11:32:12 +02:00
nobswebdev 1143b8873c update deployment guide for bitcoin payments 2026-09-03 23:44:54 +02:00
nobswebdev bfb7b013f9 add electrum wallet daemon with container entrypoint 2026-09-03 23:13:20 +02:00
nobswebdev cde7fab90c move monero wallet creation into container entrypoint 2026-09-03 23:03:49 +02:00
nobswebdev 9241474d48 add electrum config 2026-09-03 13:30:58 +02:00
nobswebdev 7ee3bcb271 guard against missing shipping invoice 2026-09-03 11:46:31 +02:00
nobswebdev 5c888551db refactor invoice required confirmations resolver to be more scalable for future confirmations based coins 2026-09-03 11:45:30 +02:00
nobswebdev 932cbf64c1 fix derive invoice state spec 2026-09-03 11:39:33 +02:00
nobswebdev 947dd23dd5 add btc details invoice relation 2026-09-03 11:05:44 +02:00
nobswebdev 11c94c7834 add bitcoin shop config and payment method enablement 2026-09-03 01:00:07 +02:00
nobswebdev a7282a1888 refactor confirmation tiers to shared crypto-agnostic types and validation 2026-09-03 00:28:17 +02:00
nobswebdev 5db64ea905 add bitcoin utils 2026-09-02 15:51:33 +02:00
243 changed files with 5963 additions and 854 deletions
+31 -5
View File
@@ -19,6 +19,7 @@ NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
CLEARNET_DOMAIN=localhost
ONION_HOSTNAME=samplexxxxxxxxx.onion
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN_MS=604800000 # 7 days
@@ -36,16 +37,20 @@ THROTTLE_LIMIT=120
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
MULTER_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
MULTER_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
VALIDATION_VARIANT_IMAGES_MAX=10
@@ -53,6 +58,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
SIGNED_COOKIE_JWT_SECRET=change-me-in-production
@@ -87,7 +93,9 @@ COINPAPRIKA_RATE_FETCH_TIMEOUT_MS=5000
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
PAYMENT_METHODS_ENABLED=xmr,btc
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
MONERO_VERSION=0.18.3.4
MONERO_NETWORK=stagenet
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
@@ -99,6 +107,22 @@ MONERO_WALLET_RPC_TIMEOUT_MS=10000
MONERO_WALLET_DIR=./monero-wallet-rpc/wallet
MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"200","minConfirmations":1},{"upToTotalFiat":"1000","minConfirmations":2},{"upToTotalFiat":"2000","minConfirmations":3},{"minConfirmations":6}]'
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
ELECTRUM_VERSION=4.8.1
ELECTRUM_NETWORK=testnet4
ELECTRUM_SERVER=testnet4-electrumx.wakiyamap.dev:51002:s
ELECTRUM_DAEMON_HOST=electrum-daemon
ELECTRUM_DAEMON_PORT=7777
ELECTRUM_DAEMON_RPC_USER=electrum
ELECTRUM_DAEMON_RPC_PASSWORD=change-me
ELECTRUM_DAEMON_RPC_TIMEOUT_MS=10000
ELECTRUM_WALLET_DIR=./electrum-daemon/wallet
ELECTRUM_WALLET_NAME=shop
ELECTRUM_WALLET_PASSWORD=change-me
SIMPLEX_CHAT_VERSION=v6.5.6
SIMPLEX_WS_URL=ws://simplex-cli:5225
@@ -110,19 +134,20 @@ ORDER_CHECKOUT_STATUS_REFRESH_SEC=15
ORDER_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours
ORDER_DATA_RETENTION_DAYS=30
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR
VITE_API_BASE_URL=http://localhost:3000/api
VITE_SHOP_FIAT_CURRENCY=USD
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
VITE_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
VITE_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
VITE_VALIDATION_VARIANT_IMAGES_MAX=10
@@ -130,4 +155,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
+1
View File
@@ -5,6 +5,7 @@
/deploy/certbot/www
/monero-wallet-rpc/wallet
/electrum-daemon/wallet
/backend/node_modules
/backend/dist
+1 -4
View File
@@ -1,6 +1,6 @@
# NullCart
Self-hosted Monero shop with clearnet (HTTPS) and Tor onion hosting.
Self-hosted crypto shop with clearnet (HTTPS) and Tor onion hosting.
For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md).
@@ -12,9 +12,6 @@ For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md
# Create and edit .env.dev as needed
cp .env.example .env.dev
# Create monero wallet used by shop
./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.dev
# Build and start docker containers
docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
+9 -1
View File
@@ -13,15 +13,18 @@ import {
getOrderConfig,
getInvoiceConfig,
getMoneroWalletConfig,
getElectrumWalletConfig,
getPostgresConfig,
getShopSettingsConfig,
getSimplexConfig
} from './config';
import { validate } from './config/validate';
import { AuthModule } from './modules/auth/AuthModule';
import { BlogModule } from './modules/blog/BlogModule';
import { EncryptionModule } from './modules/encryption/EncryptionModule';
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
import { BitcoinWalletModule } from './modules/bitcoinWallet/BitcoinWalletModule';
import { SimplexModule } from './modules/simplex/SimplexModule';
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
@@ -35,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule';
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
import { Config } from './types/Config';
@@ -55,6 +59,7 @@ import { Config } from './types/Config';
registerAs('order', getOrderConfig),
registerAs('invoice', getInvoiceConfig),
registerAs('moneroWallet', getMoneroWalletConfig),
registerAs('electrumWallet', getElectrumWalletConfig),
registerAs('simplex', getSimplexConfig)
]
}),
@@ -82,6 +87,7 @@ import { Config } from './types/Config';
HealthCheckModule,
EncryptionModule,
AuthModule,
BlogModule,
ProductsModule,
ShopSettingsModule,
SimplexModule,
@@ -91,11 +97,13 @@ import { Config } from './types/Config';
PaymentModule,
DataWipeModule,
MoneroWalletModule,
BitcoinWalletModule,
StorefrontCoreModule,
StorefrontProductModule,
StorefrontCartModule,
StorefrontCheckoutModule,
StorefrontOrderModule
StorefrontOrderModule,
StorefrontBlogModule
],
providers: [
{
+35 -4
View File
@@ -10,13 +10,16 @@ import {
PostgresConfig,
ShopSettingsConfig
} from '../types/Config';
import { ElectrumNetwork } from '../types/ElectrumNetwork';
import { ElectrumWalletConfig } from '../types/ElectrumWalletConfig';
import { MoneroNetwork } from '../types/MoneroNetwork';
import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
import { MoneroConfirmationTier } from '../types/MoneroConfirmationTier';
import { ConfirmationTier } from '../types/ConfirmationTier';
import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
import { SimplexConfig } from '../types/SimplexConfig';
import { parseEnabledPaymentMethods } from '../utils/payment/parseEnabledPaymentMethods';
const env = (key: string): string => process.env[key] || '';
@@ -94,13 +97,15 @@ export const getAppConfig = (): AppConfig => {
},
validation: {
productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'),
blogPostTitleMaxLength: envInt('VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'),
categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'),
discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'),
variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'),
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH')
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH'),
bitcoinWithdrawMaxFeeRateSatVbyte: envInt('VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE')
}
};
};
@@ -123,6 +128,17 @@ export const getProductThumbMulterConfig = (): MulterConfig => {
return { allowedMimes, maxFileBytes };
};
export const getBlogImageMulterConfig = (): MulterConfig => {
const allowedMimes = env('MULTER_BLOG_IMAGE_ALLOWED_MIMES')
.split(',')
.map(v => v.trim())
.filter(Boolean);
const maxFileBytes = envInt('MULTER_BLOG_IMAGE_MAX_FILE_BYTES');
return { allowedMimes, maxFileBytes };
};
export const getShopLogoMulterConfig = (): MulterConfig => {
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
.split(',')
@@ -177,8 +193,12 @@ export const getEncryptionConfig = (): EncryptionConfig => ({
export const getShopSettingsConfig = (): ShopSettingsConfig => ({
shopName: env('SHOP_NAME'),
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
enabledPaymentMethods: parseEnabledPaymentMethods(env('PAYMENT_METHODS_ENABLED')),
monero: {
confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as MoneroConfirmationTier[]
confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as ConfirmationTier[]
},
bitcoin: {
confirmationTiers: JSON.parse(env('BITCOIN_CONFIRMATION_TIERS')) as ConfirmationTier[]
}
});
@@ -192,7 +212,7 @@ export const getOrderConfig = (): OrderConfig => ({
export const getInvoiceConfig = (): InvoiceConfig => ({
minByMethod: {
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')),
[PaymentMethod.Btc]: String(envInt('BTC_MIN_INCOMING_ATOMIC'))
[PaymentMethod.Btc]: String(envInt('BITCOIN_MIN_INCOMING_ATOMIC'))
}
});
@@ -205,6 +225,16 @@ export const getMoneroWalletConfig = (): MoneroWalletConfig => ({
rpcTimeoutMs: envInt('MONERO_WALLET_RPC_TIMEOUT_MS')
});
export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
network: env('ELECTRUM_NETWORK') as ElectrumNetwork,
server: env('ELECTRUM_SERVER'),
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
username: env('ELECTRUM_DAEMON_RPC_USER'),
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
walletPassword: env('ELECTRUM_WALLET_PASSWORD'),
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
});
export const getSimplexConfig = (): SimplexConfig => ({
wsUrl: env('SIMPLEX_WS_URL'),
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
@@ -221,5 +251,6 @@ export default () => ({
order: getOrderConfig(),
invoice: getInvoiceConfig(),
moneroWallet: getMoneroWalletConfig(),
electrumWallet: getElectrumWalletConfig(),
simplex: getSimplexConfig()
});
@@ -0,0 +1,16 @@
import path from 'node:path';
export const storefrontImgUrlPrefix = '/shop/assets/img';
export const storefrontCssUrlPrefix = '/shop/assets/css';
export const storefrontJsUrlPrefix = '/shop/assets/js';
export const getStorefrontPublicDir = (): string =>
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css');
export const getStorefrontJsDir = (): string => path.join(getStorefrontPublicDir(), 'js');
+14
View File
@@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/';
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`;
export const getBlogImagesDir = (): string => join(getPublicUploadsDir(), 'blog', 'images');
export const blogImagesUrlPrefix = '/uploads/public/blog/images/';
export const getBlogImagePublicUrl = (filename: string): string => `${blogImagesUrlPrefix}${filename}`;
export const resolveBlogImagePath = (storageKey: string): string => {
const baseDir = getBlogImagesDir();
const absolutePath = resolvePathWithinDir(baseDir, storageKey);
return absolutePath;
};
export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
export const shopBrandingUrlPrefix = '/uploads/public/shop/';
+77 -2
View File
@@ -3,7 +3,9 @@ import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSy
import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { IsBase64 } from '../validation/decorators/isBase64';
import { IsMoneroConfirmationTiers } from '../validation/decorators/isMoneroConfirmationTiers';
import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers';
import { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods';
import { ElectrumNetwork } from '../types/ElectrumNetwork';
import { MoneroNetwork } from '../types/MoneroNetwork';
class EnvironmentVariables {
@@ -73,6 +75,16 @@ class EnvironmentVariables {
@Max(10 * 1024 * 1024) // 10MB
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
@IsNotEmpty()
@IsString()
MULTER_BLOG_IMAGE_ALLOWED_MIMES: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(10 * 1024 * 1024) // 10MB
MULTER_BLOG_IMAGE_MAX_FILE_BYTES: number;
@IsNotEmpty()
@IsString()
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
@@ -99,6 +111,12 @@ class EnvironmentVariables {
@Max(256)
VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(256)
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@@ -151,6 +169,12 @@ class EnvironmentVariables {
@Max(10000)
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(1000)
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: number;
@IsNotEmpty()
@IsNumber()
@Min(4)
@@ -265,9 +289,19 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsString()
@IsMoneroConfirmationTiers()
@IsConfirmationTiers()
MONERO_CONFIRMATION_TIERS: string;
@IsNotEmpty()
@IsString()
@IsConfirmationTiers()
BITCOIN_CONFIRMATION_TIERS: string;
@IsNotEmpty()
@IsString()
@IsEnabledPaymentMethods()
PAYMENT_METHODS_ENABLED: string;
@IsNotEmpty()
@IsNumber()
@Min(60000)
@@ -294,6 +328,12 @@ class EnvironmentVariables {
@Max(Number.MAX_SAFE_INTEGER)
MONERO_MIN_INCOMING_ATOMIC: number;
@IsNotEmpty()
@IsNumber()
@Min(0)
@Max(Number.MAX_SAFE_INTEGER)
BITCOIN_MIN_INCOMING_ATOMIC: number;
@IsNotEmpty()
@IsString()
MONERO_DAEMON_ADDRESS: string;
@@ -325,6 +365,41 @@ class EnvironmentVariables {
@Min(1000)
MONERO_WALLET_RPC_TIMEOUT_MS: number;
@IsNotEmpty()
@IsEnum(ElectrumNetwork)
ELECTRUM_NETWORK: ElectrumNetwork;
@IsNotEmpty()
@IsString()
ELECTRUM_SERVER: string;
@IsNotEmpty()
@IsString()
ELECTRUM_DAEMON_HOST: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(65535)
ELECTRUM_DAEMON_PORT: number;
@IsNotEmpty()
@IsString()
ELECTRUM_DAEMON_RPC_USER: string;
@IsNotEmpty()
@IsString()
ELECTRUM_DAEMON_RPC_PASSWORD: string;
@IsNotEmpty()
@IsNumber()
@Min(1000)
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
@IsNotEmpty()
@IsString()
ELECTRUM_WALLET_PASSWORD: string;
@IsNotEmpty()
@IsString()
SIMPLEX_WS_URL: string;
+3
View File
@@ -0,0 +1,3 @@
import Decimal from 'decimal.js';
export const BTC_ATOMIC_PER_BTC = new Decimal(100_000_000);
@@ -0,0 +1,7 @@
import { storefrontImgUrlPrefix } from '../config/storefrontAssetPaths';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodIconUrl: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: `${storefrontImgUrlPrefix}/xmr.png`,
[PaymentMethod.Btc]: `${storefrontImgUrlPrefix}/btc.png`
};
+6
View File
@@ -0,0 +1,6 @@
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodLabel: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: 'XMR',
[PaymentMethod.Btc]: 'BTC'
};
@@ -0,0 +1 @@
export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const;
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBtcInvoiceDetails1784800000000 implements MigrationInterface {
name = 'AddBtcInvoiceDetails1784800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "public"."invoices_paymentmethod_enum" ADD VALUE 'btc'`);
await queryRunner.query(
`CREATE TABLE "invoice_btc_details" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fiatPerBtcAtCreation" numeric(12,2) NOT NULL, "requiredConfirmations" integer NOT NULL, "invoiceId" uuid, CONSTRAINT "UQ_invoice_btc_details_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_invoice_btc_details" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`ALTER TABLE "invoice_btc_details" ADD CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "invoice_btc_details" DROP CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d"`);
await queryRunner.query(`DROP TABLE "invoice_btc_details"`);
}
}
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlog1784900000000 implements MigrationInterface {
name = 'AddBlog1784900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailStorageKey" character varying, "bannerStorageKey" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))`
);
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "blogEnabled"`);
await queryRunner.query(`DROP TABLE "blog_posts"`);
}
}
+22 -4
View File
@@ -5,6 +5,14 @@ import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser';
import hbs from 'hbs';
import path from 'node:path';
import {
getStorefrontCssDir,
getStorefrontImgDir,
getStorefrontJsDir,
storefrontCssUrlPrefix,
storefrontImgUrlPrefix,
storefrontJsUrlPrefix
} from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule';
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
@@ -26,10 +34,18 @@ async function bootstrap() {
immutable: true
});
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public');
app.useStaticAssets(getStorefrontImgDir(), {
prefix: storefrontImgUrlPrefix,
maxAge: '1y',
immutable: true
});
app.useStaticAssets(storefrontPublicDir, {
prefix: '/shop/assets'
app.useStaticAssets(getStorefrontCssDir(), {
prefix: storefrontCssUrlPrefix
});
app.useStaticAssets(getStorefrontJsDir(), {
prefix: storefrontJsUrlPrefix
});
app.use(cookieParser());
@@ -61,7 +77,9 @@ async function bootstrap() {
{ path: '/', method: RequestMethod.GET },
{ path: 'shop/categories/:id', method: RequestMethod.GET },
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
{ path: 'shop/assets/(.*)', method: RequestMethod.GET },
{ path: 'shop/blog', method: RequestMethod.GET },
{ path: 'shop/blog/:id', method: RequestMethod.GET },
{ path: 'shop/assets/{*path}', method: RequestMethod.GET },
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
{ path: 'shop/error', method: RequestMethod.GET },
{ path: 'shop/cart', method: RequestMethod.GET },
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/AuthModule';
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
@Module({
imports: [AuthModule],
controllers: [BitcoinWalletController],
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
exports: [ElectrumWalletRpcClient]
})
export class BitcoinWalletModule {}
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtGuard } from '../../../guards/JwtGuard';
import { throttleProfiles } from '../../../config/throttleProfiles';
import { BitcoinWalletRevealSeedDto } from '../dto/BitcoinWalletRevealSeedDto';
import { BitcoinWalletWithdrawDto } from '../dto/BitcoinWalletWithdrawDto';
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
@Controller('bitcoin-wallet')
@UseGuards(JwtGuard)
export class BitcoinWalletController {
constructor(private readonly walletAdminService: BitcoinWalletAdminService) {}
@Get('/')
getStatus() {
return this.walletAdminService.getStatus();
}
@Post('/withdraw')
@Throttle(throttleProfiles.walletWithdraw)
withdraw(@Body() { destinationAddress, feeRateSatVbyte, password }: BitcoinWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, feeRateSatVbyte, password);
}
@Post('/reveal-seed')
@Throttle(throttleProfiles.walletRevealSeed)
revealSeed(@Body() { password }: BitcoinWalletRevealSeedDto) {
return this.walletAdminService.revealSeed(password);
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class BitcoinWalletRevealSeedDto {
@IsString()
@IsNotEmpty()
password: string;
}
@@ -0,0 +1,25 @@
import { Transform } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { getAppConfig } from '../../../config';
import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress';
const {
validation: { bitcoinWithdrawMaxFeeRateSatVbyte }
} = getAppConfig();
export class BitcoinWalletWithdrawDto {
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
@IsBitcoinAddress()
destinationAddress: string;
@IsInt()
@Min(1)
@Max(bitcoinWithdrawMaxFeeRateSatVbyte)
feeRateSatVbyte: number;
@IsString()
@IsNotEmpty()
password: string;
}
@@ -0,0 +1,147 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { AuthService } from '../../auth/services/AuthService';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
describe('BitcoinWalletAdminService', () => {
let service: BitcoinWalletAdminService;
let walletRpcClient: {
getVersion: jest.Mock;
isSynchronized: jest.Mock;
getInfo: jest.Mock;
getBalance: jest.Mock;
sweepAll: jest.Mock;
getSeed: jest.Mock;
};
let authService: {
verifyPassword: jest.Mock;
};
let configService: {
get: jest.Mock;
};
beforeEach(() => {
walletRpcClient = {
getVersion: jest.fn().mockResolvedValue('4.8.1'),
isSynchronized: jest.fn().mockResolvedValue(true),
getInfo: jest.fn().mockResolvedValue({
blockchain_height: 900_000,
server_height: 900_000
}),
getBalance: jest.fn().mockResolvedValue({
balanceAtomic: '150000',
confirmedBalanceAtomic: '150000'
}),
sweepAll: jest.fn().mockResolvedValue({
txHash: 'tx-hash-1',
amountAtomic: '140000'
}),
getSeed: jest.fn().mockResolvedValue('seed words')
};
authService = {
verifyPassword: jest.fn()
};
configService = {
get: jest.fn().mockReturnValue({
network: 'testnet4'
})
};
service = new BitcoinWalletAdminService(
walletRpcClient as unknown as ElectrumWalletRpcClient,
authService as unknown as AuthService,
configService as unknown as ConfigService
);
});
it('returns wallet status when RPC calls succeed', async () => {
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
network: 'testnet4',
rpcVersion: '4.8.1',
blockHeight: 900_000,
serverHeight: 900_000,
syncStatus: WalletSyncStatus.Synced,
balanceBtc: '0.00150000',
confirmedBalanceBtc: '0.00150000'
})
);
});
it('reports syncing when the wallet is not synchronized', async () => {
walletRpcClient.isSynchronized.mockResolvedValue(false);
const status = await service.getStatus();
expect(status.syncStatus).toBe(WalletSyncStatus.Syncing);
});
it('throws when RPC calls fail', async () => {
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('rejects withdrawals when there is no confirmed balance', async () => {
walletRpcClient.getBalance.mockResolvedValue({
balanceAtomic: '0',
confirmedBalanceAtomic: '0'
});
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
new BadRequestException('No confirmed balance to withdraw.')
);
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
});
it('rejects withdrawals while the wallet is still syncing', async () => {
walletRpcClient.isSynchronized.mockResolvedValue(false);
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
);
});
it('sweeps confirmed funds when the wallet is synced', async () => {
const result = await service.withdrawAll('bc1qdestination', 12, 'password');
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('bc1qdestination', 12);
expect(result).toEqual({
txHash: 'tx-hash-1',
amountBtc: '0.00140000'
});
});
it('reveals the wallet seed after password verification', async () => {
await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.getSeed).toHaveBeenCalled();
});
it('throws when seed reveal fails', async () => {
walletRpcClient.getSeed.mockRejectedValue(new Error('rpc down'));
await expect(service.revealSeed('password')).rejects.toThrow(
new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.')
);
});
it('throws when withdrawal fails after prechecks pass', async () => {
walletRpcClient.sweepAll.mockRejectedValue(new Error('payto failed'));
await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
)
);
});
});
@@ -0,0 +1,123 @@
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../../auth/services/AuthService';
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
import type { Config } from '../../../types/Config';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { BitcoinWalletRevealSeedResult } from '../types/BitcoinWalletRevealSeedResult';
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
import type { BitcoinWalletWithdrawResult } from '../types/BitcoinWalletWithdrawResult';
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
@Injectable()
export class BitcoinWalletAdminService {
constructor(
private readonly walletRpcClient: ElectrumWalletRpcClient,
private readonly authService: AuthService,
private readonly configService: ConfigService
) {}
async getStatus(): Promise<BitcoinWalletStatusView> {
const { network } = this.configService.get('electrumWallet') as Config['electrumWallet'];
try {
const [rpcVersion, isSynchronized, info, { balanceAtomic, confirmedBalanceAtomic }] = await Promise.all([
this.walletRpcClient.getVersion(),
this.walletRpcClient.isSynchronized(),
this.walletRpcClient.getInfo(),
this.walletRpcClient.getBalance()
]);
const blockHeight = info.blockchain_height ?? null;
const serverHeight = info.server_height ?? null;
return {
network,
rpcVersion,
blockHeight,
serverHeight,
syncStatus: this.resolveSyncStatus(isSynchronized, blockHeight, serverHeight),
balanceBtc: convertBtcAtomicToBtc(balanceAtomic),
confirmedBalanceBtc: convertBtcAtomicToBtc(confirmedBalanceAtomic)
};
} catch {
throw new ServiceUnavailableException(
'Could not load wallet status. The Bitcoin wallet may be busy or unavailable.'
);
}
}
async withdrawAll(
destinationAddress: string,
feeRateSatVbyte: number,
password: string
): Promise<BitcoinWalletWithdrawResult> {
this.authService.verifyPassword(password);
let isSynchronized: boolean;
let confirmedBalanceAtomic: string;
try {
const [syncResult, balanceResult] = await Promise.all([
this.walletRpcClient.isSynchronized(),
this.walletRpcClient.getBalance()
]);
isSynchronized = syncResult;
confirmedBalanceAtomic = balanceResult.confirmedBalanceAtomic;
} catch {
throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
}
if (confirmedBalanceAtomic === '0') {
throw new BadRequestException('No confirmed balance to withdraw.');
}
if (!isSynchronized) {
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
}
let sweepResult: { txHash: string; amountAtomic: string };
try {
sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, feeRateSatVbyte);
} catch {
throw new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
);
}
return {
txHash: sweepResult.txHash,
amountBtc: convertBtcAtomicToBtc(sweepResult.amountAtomic)
};
}
async revealSeed(password: string): Promise<BitcoinWalletRevealSeedResult> {
this.authService.verifyPassword(password);
try {
const mnemonic = await this.walletRpcClient.getSeed();
return { mnemonic };
} catch {
throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
}
}
private resolveSyncStatus(
isSynchronized: boolean,
blockHeight: number | null,
serverHeight: number | null
): WalletSyncStatus {
if (isSynchronized) {
return WalletSyncStatus.Synced;
}
if (blockHeight === null || serverHeight === null) {
return WalletSyncStatus.Unknown;
}
return WalletSyncStatus.Syncing;
}
}
@@ -0,0 +1,447 @@
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type { ElectrumWalletRpcClientTest } from '../types/ElectrumWalletRpcClientTest';
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('ElectrumWalletRpcClient', () => {
let client: ElectrumWalletRpcClient;
let clientTest: ElectrumWalletRpcClientTest;
beforeEach(() => {
client = new ElectrumWalletRpcClient({
get: jest.fn().mockReturnValue({
rpcUrl: 'http://electrum.test:7777',
username: 'electrum',
password: 'secret',
walletPassword: 'wallet-secret',
rpcTimeoutMs: 5000
})
} as unknown as ConfigService);
clientTest = client as unknown as ElectrumWalletRpcClientTest;
mockedAxios.post.mockReset();
});
describe('mapIncomingTransfer', () => {
it('maps confirmed transfers with confirmations derived from block height', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: []
});
});
it('returns zero confirmations for unconfirmed transfers', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 0
},
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 0,
inputOutpoints: []
});
});
it('returns null for non-positive amounts', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
'bc1qtest',
800_002
)
).toBeNull();
});
it('maps input outpoints from transaction inputs', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [
{ prevout_hash: 'abc123', prevout_n: 0 },
{ prevout_hash: 'def456', prevout_n: 2 }
],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['abc123:0', 'def456:2']
});
});
it('skips coinbase-like inputs without prevout data', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [{}, { prevout_hash: '', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: []
});
});
});
describe('sumOutputValueAtomic', () => {
it('sums outputs paying to the target address', () => {
expect(
clientTest.sumOutputValueAtomic(
{
outputs: [
{ address: 'bc1qother', value_sats: 10_000 },
{ address: 'bc1qtest', value_sats: 50_000 },
{ address: 'bc1qtest', value_sats: 25_000 }
]
},
'bc1qtest'
)
).toBe('75000');
});
it('returns zero when no outputs match the address', () => {
expect(
clientTest.sumOutputValueAtomic(
{
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
'bc1qtest'
)
).toBe('0');
});
});
describe('getIncomingTransfers', () => {
it('resolves incoming amounts from transaction outputs', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: [{ tx_hash: 'abc123', height: 800_000 }]
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '01000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
outputs: [
{ address: 'bc1qother', value_sats: 10_000 },
{ address: 'bc1qtest', value_sats: 50_000 }
]
}
}
});
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
{
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['input123:0']
}
]);
expect(mockedAxios.post).toHaveBeenNthCalledWith(
2,
'http://electrum.test:7777',
{
jsonrpc: '2.0',
id: 'nullcart',
method: 'gettransaction',
params: { txid: 'abc123' }
},
expect.any(Object)
);
expect(mockedAxios.post).toHaveBeenNthCalledWith(
3,
'http://electrum.test:7777',
{
jsonrpc: '2.0',
id: 'nullcart',
method: 'deserialize',
params: { tx: '01000000' }
},
expect.any(Object)
);
});
it('drops superseded unconfirmed transfers that share inputs with a confirmed replacement', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: [
{ tx_hash: 'original', height: 0 },
{ tx_hash: 'replacement', height: 800_000 }
]
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '01000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '02000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
});
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
{
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['shared-input:0']
}
]);
});
});
describe('filterSupersededBitcoinTransfers', () => {
it('keeps unrelated transfers unchanged', () => {
const transfers = [
{ txHash: 'a', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'b', amountAtomic: '1', confirmations: 3, inputOutpoints: ['in2:1'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
it('drops an unconfirmed transfer superseded by a confirmed replacement', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
]);
});
it('keeps the later unconfirmed transfer when both conflict before confirmation', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
]);
});
it('keeps transfers without input outpoints', () => {
const transfers = [
{ txHash: 'coinbase', amountAtomic: '1', confirmations: 0, inputOutpoints: [] },
{ txHash: 'payment', amountAtomic: '1', confirmations: 1, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
});
describe('createAddress', () => {
it('creates an address and sets a label when provided', async () => {
mockedAxios.post
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: 'bc1qtest' } })
.mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: true } });
await expect(client.createAddress('checkout - order-1')).resolves.toBe('bc1qtest');
expect(mockedAxios.post).toHaveBeenNthCalledWith(
2,
'http://electrum.test:7777',
{
jsonrpc: '2.0',
id: 'nullcart',
method: 'setlabel',
params: { key: 'bc1qtest', label: 'checkout - order-1' }
},
expect.objectContaining({
auth: { username: 'electrum', password: 'secret' }
})
);
});
});
describe('getBalance', () => {
it('returns confirmed and total balances in atomic units', async () => {
mockedAxios.post.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
confirmed: '0.00025',
unconfirmed: '0.0001'
}
}
});
await expect(client.getBalance()).resolves.toEqual({
balanceAtomic: '35000',
confirmedBalanceAtomic: '25000'
});
});
});
describe('sweepAll', () => {
it('creates, signs, and broadcasts a max-payment transaction', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: 'signed-tx'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
outputs: [{ address: 'bc1qtest', value_sats: 140_000 }]
}
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: 'tx-hash-1'
}
});
await expect(client.sweepAll('bc1qtest', 10)).resolves.toEqual({
txHash: 'tx-hash-1',
amountAtomic: '140000'
});
expect(mockedAxios.post).toHaveBeenNthCalledWith(
1,
'http://electrum.test:7777',
{
jsonrpc: '2.0',
id: 'nullcart',
method: 'payto',
params: {
destination: 'bc1qtest',
amount: '!',
feerate: '10',
password: 'wallet-secret'
}
},
expect.objectContaining({
timeout: 120_000
})
);
expect(mockedAxios.post).toHaveBeenNthCalledWith(
2,
'http://electrum.test:7777',
{
jsonrpc: '2.0',
id: 'nullcart',
method: 'deserialize',
params: { tx: 'signed-tx' }
},
expect.any(Object)
);
expect(mockedAxios.post).toHaveBeenNthCalledWith(
3,
'http://electrum.test:7777',
{
jsonrpc: '2.0',
id: 'nullcart',
method: 'broadcast',
params: { tx: 'signed-tx' }
},
expect.any(Object)
);
});
});
});
@@ -0,0 +1,290 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type { Config } from '../../../types/Config';
import { addAtomic } from '../../../utils/atomic/addAtomic';
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
import type { ElectrumWalletAddressHistoryEntry } from '../types/ElectrumWalletAddressHistoryEntry';
import type { ElectrumWalletDeserializedTransaction } from '../types/ElectrumWalletDeserializedTransaction';
import type { ElectrumWalletGetBalanceResult } from '../types/ElectrumWalletGetBalanceResult';
import type { ElectrumWalletGetInfoResult } from '../types/ElectrumWalletGetInfoResult';
import type { ElectrumWalletIncomingTransfer } from '../types/ElectrumWalletIncomingTransfer';
import type { ElectrumWalletRpcResponse } from '../types/ElectrumWalletRpcResponse';
@Injectable()
export class ElectrumWalletRpcClient {
constructor(private readonly configService: ConfigService) {}
private async call<T>(
method: string,
params: Record<string, unknown> | unknown[] = {},
options: { timeoutMs?: number } = {}
): Promise<T> {
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
'electrumWallet'
) as Config['electrumWallet'];
const { data } = await axios.post<ElectrumWalletRpcResponse<T>>(
rpcUrl,
{
jsonrpc: '2.0',
id: 'nullcart',
method,
params
},
{
timeout: options.timeoutMs ?? rpcTimeoutMs,
auth: {
username,
password
}
}
);
if (data.error) {
throw new Error(data.error.message);
}
if (data.result === undefined) {
throw new Error(`Electrum wallet RPC ${method} returned no result`);
}
return data.result;
}
async getVersion(): Promise<string> {
const version = await this.call<string>('version');
if (!version) {
throw new Error('Electrum wallet RPC version returned no version');
}
return version;
}
async isSynchronized(): Promise<boolean> {
return this.call<boolean>('is_synchronized');
}
async getInfo(): Promise<ElectrumWalletGetInfoResult> {
return this.call<ElectrumWalletGetInfoResult>('getinfo');
}
async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> {
const { confirmed, unconfirmed } = await this.call<ElectrumWalletGetBalanceResult>('getbalance');
if (confirmed === undefined) {
throw new Error('Electrum wallet RPC getbalance returned incomplete result');
}
const confirmedAtomic = convertBtcToBtcAtomic(confirmed);
const unconfirmedAtomic =
unconfirmed !== undefined && unconfirmed !== '0' ? convertBtcToBtcAtomic(unconfirmed) : '0';
return {
balanceAtomic: addAtomic(confirmedAtomic, unconfirmedAtomic),
confirmedBalanceAtomic: confirmedAtomic
};
}
async sweepAll(
destinationAddress: string,
feeRateSatVbyte: number
): Promise<{ txHash: string; amountAtomic: string }> {
const signedTransaction = await this.payToMax(destinationAddress, feeRateSatVbyte);
const transaction = await this.deserializeTransaction(signedTransaction);
const amountAtomic = this.sumOutputValueAtomic(transaction, destinationAddress);
if (amountAtomic === '0') {
throw new Error('Withdrawal transaction has no spendable output to the destination address');
}
const txHash = await this.broadcast(signedTransaction);
return { txHash, amountAtomic };
}
private async payToMax(destinationAddress: string, feeRateSatVbyte: number): Promise<string> {
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
const signedTransaction = await this.call<string>(
'payto',
{
destination: destinationAddress,
amount: '!',
feerate: String(feeRateSatVbyte),
password: walletPassword
},
{ timeoutMs: 120_000 }
);
if (!signedTransaction) {
throw new Error('Electrum wallet RPC payto returned no transaction');
}
return signedTransaction;
}
private async broadcast(signedTransaction: string): Promise<string> {
const txHash = await this.call<string>('broadcast', { tx: signedTransaction });
if (!txHash) {
throw new Error('Electrum wallet RPC broadcast returned no transaction hash');
}
return txHash;
}
private async deserializeTransaction(signedTransaction: string): Promise<ElectrumWalletDeserializedTransaction> {
return this.call<ElectrumWalletDeserializedTransaction>('deserialize', { tx: signedTransaction });
}
private sumOutputValueAtomic(transaction: ElectrumWalletDeserializedTransaction, address: string): string {
if (!Array.isArray(transaction.outputs)) {
return '0';
}
return transaction.outputs.reduce((sum, output) => {
if (output.address !== address || !Number.isFinite(output.value_sats) || output.value_sats <= 0) {
return sum;
}
return addAtomic(sum, String(output.value_sats));
}, '0');
}
async getSeed(): Promise<string> {
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
const mnemonic = await this.call<string>('getseed', { password: walletPassword });
if (!mnemonic) {
throw new Error('Electrum wallet RPC getseed returned no mnemonic');
}
return mnemonic;
}
async createAddress(label?: string): Promise<string> {
const address = await this.call<string>('createnewaddress');
if (!address) {
throw new Error('Electrum wallet RPC createnewaddress returned no address');
}
if (label) {
await this.call('setlabel', { key: address, label });
}
return address;
}
async getIncomingTransfers(address: string, blockHeight: number | null): Promise<ElectrumWalletIncomingTransfer[]> {
const history = await this.call<ElectrumWalletAddressHistoryEntry[]>('getaddresshistory', {
address
});
if (!Array.isArray(history)) {
throw new Error('Electrum wallet RPC getaddresshistory returned invalid result');
}
const transfers = await Promise.all(
history.map(async entry => {
const txHash = entry.tx_hash;
if (!txHash) {
return null;
}
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.deserializeTransaction(serializedTransaction);
return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
})
);
const resolvedTransfers = transfers.filter(
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
);
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
}
private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
): ElectrumWalletIncomingTransfer | null {
const txHash = entry.tx_hash;
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
if (!txHash || amountAtomic === '0') {
return null;
}
const confirmations =
entry.height > 0 && blockHeight !== null ? Math.max(blockHeight - entry.height + 1, 0) : 0;
return {
txHash,
amountAtomic,
confirmations,
inputOutpoints: this.extractInputOutpoints(transaction)
};
}
private filterSupersededBitcoinTransfers(
transfers: ElectrumWalletIncomingTransfer[]
): ElectrumWalletIncomingTransfer[] {
return transfers.filter((transfer, index) => {
const isSuperseded = transfers.some((other, otherIndex) => {
if (otherIndex === index) {
return false;
}
if (!this.sharesInputOutpoint(transfer.inputOutpoints, other.inputOutpoints)) {
return false;
}
if (other.confirmations > transfer.confirmations) {
return true;
}
return other.confirmations === transfer.confirmations && otherIndex > index;
});
return !isSuperseded;
});
}
private extractInputOutpoints(transaction: ElectrumWalletDeserializedTransaction): string[] {
if (!Array.isArray(transaction.inputs)) {
return [];
}
return transaction.inputs.flatMap(input => {
if (typeof input.prevout_hash !== 'string' || input.prevout_hash.length === 0) {
return [];
}
if (typeof input.prevout_n !== 'number') {
return [];
}
return [`${input.prevout_hash}:${input.prevout_n}`];
});
}
private sharesInputOutpoint(left: readonly string[], right: readonly string[]): boolean {
if (left.length === 0 || right.length === 0) {
return false;
}
const rightOutpoints = new Set(right);
return left.some(outpoint => rightOutpoints.has(outpoint));
}
}
@@ -0,0 +1,20 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
@Injectable()
export class ElectrumWalletRpcConnectionService implements OnModuleInit {
private readonly logger = new Logger(ElectrumWalletRpcConnectionService.name);
constructor(private readonly walletRpcClient: ElectrumWalletRpcClient) {}
async onModuleInit(): Promise<void> {
try {
const version = await this.walletRpcClient.getVersion();
this.logger.log(`Connected to electrum-daemon (version ${version})`);
} catch (error) {
this.logger.error(`Failed to reach electrum-daemon at startup: ${getErrorMessage(error)}`);
}
}
}
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -0,0 +1,12 @@
import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
export interface BitcoinWalletStatusView {
network: ElectrumNetwork;
rpcVersion: string;
blockHeight: number | null;
serverHeight: number | null;
syncStatus: WalletSyncStatus;
balanceBtc: string;
confirmedBalanceBtc: string;
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
@@ -0,0 +1,4 @@
export type ElectrumWalletAddressHistoryEntry = {
tx_hash: string;
height: number;
};
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedInput = {
prevout_hash?: string;
prevout_n?: number;
};
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
@@ -0,0 +1,7 @@
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
export type ElectrumWalletDeserializedTransaction = {
inputs?: ElectrumWalletDeserializedInput[];
outputs: ElectrumWalletDeserializedOutput[];
};
@@ -0,0 +1,4 @@
export type ElectrumWalletGetBalanceResult = {
confirmed: string;
unconfirmed?: string;
};
@@ -0,0 +1,5 @@
export type ElectrumWalletGetInfoResult = {
blockchain_height?: number;
server?: string;
server_height?: number;
};
@@ -0,0 +1,6 @@
export type ElectrumWalletIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
inputOutpoints: string[];
};
@@ -0,0 +1,16 @@
import type { ElectrumWalletAddressHistoryEntry } from './ElectrumWalletAddressHistoryEntry';
import type { ElectrumWalletDeserializedTransaction } from './ElectrumWalletDeserializedTransaction';
import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTransfer';
export type ElectrumWalletRpcClientTest = {
mapIncomingTransfer: (
entry: ElectrumWalletAddressHistoryEntry,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null;
filterSupersededBitcoinTransfers: (
transfers: ElectrumWalletIncomingTransfer[]
) => ElectrumWalletIncomingTransfer[];
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
};
@@ -0,0 +1,9 @@
export type ElectrumWalletRpcResponse<T> = {
id: string | number;
jsonrpc: string;
result?: T;
error?: {
code: number;
message: string;
};
};
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPostsController } from './controllers/BlogPostsController';
import { BlogPost } from './entities/BlogPost';
import { BlogPostsService } from './services/BlogPostsService';
@Module({
imports: [TypeOrmModule.forFeature([BlogPost])],
controllers: [BlogPostsController],
providers: [BlogPostsService],
exports: [TypeOrmModule.forFeature([BlogPost])]
})
export class BlogModule {}
@@ -0,0 +1,12 @@
import { getBlogImageMulterConfig } from '../../../config';
import { getBlogImagesDir } from '../../../config/uploadPaths';
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
const uploadDir = getBlogImagesDir();
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
@@ -0,0 +1,72 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtGuard } from '../../../guards/JwtGuard';
import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload';
import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
import { BlogPostsService } from '../services/BlogPostsService';
@Controller('blog-posts')
@UseGuards(JwtGuard)
export class BlogPostsController {
constructor(private readonly blogPostsService: BlogPostsService) {}
@Post('/')
createDraft() {
return this.blogPostsService.createDraft();
}
@Get('/')
findAll(@Query() query: ListBlogPostsQueryDto) {
return this.blogPostsService.findAll(query);
}
@Get('/:id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.blogPostsService.findOne(id);
}
@Patch('/:id')
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) {
return this.blogPostsService.update(id, dto);
}
@Post('/:id/thumbnail')
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
uploadThumbnail(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
) {
return this.blogPostsService.uploadThumbnail(id, file.filename);
}
@Post('/:id/banner')
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
uploadBanner(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
) {
return this.blogPostsService.uploadBanner(id, file.filename);
}
@Delete('/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string) {
await this.blogPostsService.remove(id);
}
}
@@ -0,0 +1,17 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListBlogPostsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
@@ -0,0 +1,21 @@
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { getAppConfig } from '../../../config';
const {
validation: { blogPostTitleMaxLength }
} = getAppConfig();
export class UpdateBlogPostDto {
@IsNotEmpty()
@IsString()
@MaxLength(blogPostTitleMaxLength)
title: string;
@IsString()
contentHtml: string;
@IsNotEmpty()
@IsBoolean()
isDraft: boolean;
}
@@ -0,0 +1,28 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('blog_posts')
export class BlogPost {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ type: 'varchar', nullable: true })
thumbnailStorageKey: string | null;
@Column({ type: 'varchar', nullable: true })
bannerStorageKey: string | null;
@Column({ type: 'text' })
contentHtml: string;
@Column({ type: 'boolean', default: true })
isDraft: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,181 @@
import { NotFoundException } from '@nestjs/common';
import type { Repository } from 'typeorm';
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import { BlogPost } from '../entities/BlogPost';
import { BlogPostsService } from './BlogPostsService';
jest.mock('../../../utils/removeFileFromDisk', () => ({
removeFileFromDisk: jest.fn().mockResolvedValue(undefined)
}));
describe('BlogPostsService', () => {
let service: BlogPostsService;
let blogPostRepo: {
findAndCount: jest.Mock;
findOne: jest.Mock;
create: jest.Mock;
insert: jest.Mock;
update: jest.Mock;
exists: jest.Mock;
delete: jest.Mock;
};
beforeEach(() => {
jest.mocked(removeFileFromDisk).mockClear();
blogPostRepo = {
findAndCount: jest.fn().mockResolvedValue([[], 0]),
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn(data => ({ id: 'post-1', ...data })),
insert: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
exists: jest.fn().mockResolvedValue(false),
delete: jest.fn().mockResolvedValue(undefined)
};
service = new BlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
});
it('creates a draft post with empty title and content', async () => {
blogPostRepo.findOne.mockResolvedValue({
id: 'post-1',
title: '',
contentHtml: '',
isDraft: true
});
const created = await service.createDraft();
expect(blogPostRepo.create).toHaveBeenCalledWith({
title: '',
contentHtml: '',
isDraft: true
});
expect(blogPostRepo.insert).toHaveBeenCalled();
expect(created).toEqual(
expect.objectContaining({
id: 'post-1',
title: '',
contentHtml: '',
isDraft: true,
thumbnailUrl: null,
bannerUrl: null
})
);
});
it('maps image storage keys to public urls', async () => {
blogPostRepo.findOne.mockResolvedValue({
id: 'post-1',
title: 'Hello',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: 'banner.webp',
contentHtml: '<p>Body</p>',
isDraft: false
});
await expect(service.findOne('post-1')).resolves.toEqual(
expect.objectContaining({
title: 'Hello',
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
bannerUrl: getBlogImagePublicUrl('banner.webp')
})
);
});
it('lists posts ordered by createdAt descending', async () => {
await service.findAll({ page: 2, limit: 10 });
expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({
order: { createdAt: 'DESC' },
skip: 10,
take: 10
});
});
it('trims title on update', async () => {
blogPostRepo.exists.mockResolvedValueOnce(true);
blogPostRepo.findOne.mockResolvedValueOnce({
id: 'post-1',
title: 'Hello',
contentHtml: '<p>Body</p>',
isDraft: false
});
await service.update('post-1', {
title: ' Hello ',
contentHtml: '<p>Body</p>',
isDraft: false
});
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', {
title: 'Hello',
contentHtml: '<p>Body</p>',
isDraft: false
});
});
it('uploads a thumbnail and removes the previous file', async () => {
blogPostRepo.findOne
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'old.webp' })
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'new.webp' });
await service.uploadThumbnail('post-1', 'new.webp');
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { thumbnailStorageKey: 'new.webp' });
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('old.webp'), BlogPostsService.name);
});
it('uploads a banner and removes the previous file', async () => {
blogPostRepo.findOne
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'old-banner.webp' })
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'new-banner.webp' });
await service.uploadBanner('post-1', 'new-banner.webp');
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { bannerStorageKey: 'new-banner.webp' });
expect(removeFileFromDisk).toHaveBeenCalledWith(
resolveBlogImagePath('old-banner.webp'),
BlogPostsService.name
);
});
it('throws when loading a missing post', async () => {
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
});
it('throws when updating a missing post', async () => {
blogPostRepo.exists.mockResolvedValueOnce(false);
await expect(
service.update('missing-id', {
title: 'Hello',
contentHtml: '',
isDraft: true
})
).rejects.toThrow(NotFoundException);
});
it('deletes an existing post and its image files', async () => {
blogPostRepo.findOne.mockResolvedValueOnce({
id: 'post-1',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: 'banner.webp'
});
await service.remove('post-1');
expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1');
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('thumb.webp'), BlogPostsService.name);
expect(removeFileFromDisk).toHaveBeenCalledWith(
resolveBlogImagePath('banner.webp'),
BlogPostsService.name
);
});
it('throws when deleting a missing post', async () => {
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
expect(blogPostRepo.delete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,125 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
import { BlogPost } from '../entities/BlogPost';
import type { BlogPostExtended } from '../types/BlogPostExtended';
@Injectable()
export class BlogPostsService {
constructor(
@InjectRepository(BlogPost)
private readonly blogPostRepo: Repository<BlogPost>
) {}
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
const [items, total] = await this.blogPostRepo.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit
});
const itemsExtended = items.map(post => this.toPostExtended(post));
return { items: itemsExtended, total, page, limit };
}
async createDraft(): Promise<BlogPostExtended> {
const entity = this.blogPostRepo.create({
title: '',
contentHtml: '',
isDraft: true
});
await this.blogPostRepo.insert(entity);
return this.findOne(entity.id);
}
async findOne(id: string): Promise<BlogPostExtended> {
const entity = await this.findEntity(id);
return this.toPostExtended(entity);
}
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
const exists = await this.blogPostRepo.exists({ where: { id } });
if (!exists) {
throw new NotFoundException();
}
await this.blogPostRepo.update(id, {
title: title.trim(),
contentHtml,
isDraft
});
return this.findOne(id);
}
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
const post = await this.findEntity(id);
const previousStorageKey = post.thumbnailStorageKey;
await this.blogPostRepo.update(id, { thumbnailStorageKey: filename });
await this.removeBlogImageFile(previousStorageKey);
return this.findOne(id);
}
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
const post = await this.findEntity(id);
const previousStorageKey = post.bannerStorageKey;
await this.blogPostRepo.update(id, { bannerStorageKey: filename });
await this.removeBlogImageFile(previousStorageKey);
return this.findOne(id);
}
async remove(id: string): Promise<void> {
const post = await this.findEntity(id);
await this.blogPostRepo.delete(id);
await Promise.all([
this.removeBlogImageFile(post.thumbnailStorageKey),
this.removeBlogImageFile(post.bannerStorageKey)
]);
}
private async findEntity(id: string): Promise<BlogPost> {
const entity = await this.blogPostRepo.findOne({ where: { id } });
if (!entity) {
throw new NotFoundException();
}
return entity;
}
private toPostExtended(post: BlogPost): BlogPostExtended {
return {
...post,
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null
};
}
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
if (!storageKey) {
return;
}
const path = resolveBlogImagePath(storageKey);
await removeFileFromDisk(path, BlogPostsService.name);
}
}
@@ -0,0 +1,6 @@
import type { BlogPost } from '../entities/BlogPost';
export type BlogPostExtended = BlogPost & {
thumbnailUrl: string | null;
bannerUrl: string | null;
};
@@ -18,8 +18,8 @@ export class MoneroWalletController {
@Post('/withdraw')
@Throttle(throttleProfiles.walletWithdraw)
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, password);
withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, priority, password);
}
@Post('/reveal-seed')
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString } from 'class-validator';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
export class MoneroWalletWithdrawDto {
@@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto {
@IsMoneroStandardAddress()
destinationAddress: string;
@IsNotEmpty()
@IsEnum(MoneroWithdrawPriority)
priority: MoneroWithdrawPriority;
@IsString()
@IsNotEmpty()
password: string;
@@ -2,7 +2,8 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type { AuthService } from '../../auth/services/AuthService';
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
@@ -76,7 +77,7 @@ describe('MoneroWalletAdminService', () => {
rpcVersion: '0.18.3.1',
walletHeight: 3_000_000,
daemonHeight: 3_000_000,
syncStatus: MoneroWalletSyncStatus.Synced,
syncStatus: WalletSyncStatus.Synced,
balanceXmr: '2.00000000',
unlockedBalanceXmr: '1.00000000'
})
@@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => {
unlockedBalanceAtomic: '0'
});
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
new BadRequestException('No unlocked balance to withdraw.')
);
await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.'));
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
@@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => {
it('rejects withdrawals while the wallet is still syncing', async () => {
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
);
await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(new BadRequestException('Wallet is still syncing. Try again after sync completes.'));
});
it('sweeps unlocked funds when the wallet is synced', async () => {
const result = await service.withdrawAll('4DestinationAddressExample', 'password');
const result = await service.withdrawAll(
'4DestinationAddressExample',
MoneroWithdrawPriority.Fast,
'password'
);
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith(
'4DestinationAddressExample',
MoneroWithdrawPriority.Fast
);
expect(result).toEqual({
txHashes: ['tx-hash-1'],
amountXmr: '1.00000000'
@@ -137,7 +145,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus();
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
expect(status.syncStatus).toBe(WalletSyncStatus.Unknown);
expect(status.daemonHeight).toBeNull();
});
@@ -149,7 +157,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus();
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
expect(status.syncStatus).toBe(WalletSyncStatus.Synced);
});
it('throws when reveal seed RPC fails', async () => {
@@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => {
it('throws when sweep all fails after prechecks pass', async () => {
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(
new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
)
@@ -7,7 +7,8 @@ import type { Config } from '../../../types/Config';
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
@@ -49,7 +50,11 @@ export class MoneroWalletAdminService {
}
}
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> {
async withdrawAll(
destinationAddress: string,
priority: MoneroWithdrawPriority,
password: string
): Promise<MoneroWalletWithdrawResult> {
this.authService.verifyPassword(password);
await this.walletRpcClient.tryRefresh();
@@ -59,11 +64,15 @@ export class MoneroWalletAdminService {
let daemonHeight: number | null;
try {
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([
this.walletRpcClient.getBalance(),
this.walletRpcClient.getHeight(),
this.fetchDaemonHeight()
]);
unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic;
walletHeight = walletHeightResult;
daemonHeight = daemonHeightResult;
} catch {
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
}
@@ -72,7 +81,7 @@ export class MoneroWalletAdminService {
throw new BadRequestException('No unlocked balance to withdraw.');
}
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== WalletSyncStatus.Synced) {
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
}
@@ -80,7 +89,10 @@ export class MoneroWalletAdminService {
let amountAtomic: string;
try {
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority);
txHashes = sweepResult.txHashes;
amountAtomic = sweepResult.amountAtomic;
} catch {
throw new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
@@ -125,11 +137,11 @@ export class MoneroWalletAdminService {
}
}
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): WalletSyncStatus {
if (daemonHeight === null) {
return MoneroWalletSyncStatus.Unknown;
return WalletSyncStatus.Unknown;
}
return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
return walletHeight >= daemonHeight - 1 ? WalletSyncStatus.Synced : WalletSyncStatus.Syncing;
}
}
@@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
@@ -222,14 +223,17 @@ export class MoneroWalletRpcClient {
};
}
async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> {
async sweepAll(
destinationAddress: string,
priority: MoneroWithdrawPriority
): Promise<{ txHashes: string[]; amountAtomic: string }> {
const result = await this.call<MoneroWalletRpcSweepAllResult>(
'sweep_all',
{
address: destinationAddress,
account_index: this.accountIndex,
subaddr_indices_all: true,
priority: 1
priority
},
{ timeoutMs: 120_000 }
);
@@ -1,12 +1,12 @@
import { MoneroNetwork } from '../../../types/MoneroNetwork';
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
export interface MoneroWalletStatusView {
network: MoneroNetwork;
rpcVersion: string;
walletHeight: number;
daemonHeight: number | null;
syncStatus: MoneroWalletSyncStatus;
syncStatus: WalletSyncStatus;
balanceXmr: string;
unlockedBalanceXmr: string;
}
@@ -36,6 +36,7 @@ export class OrderCreationService {
.leftJoinAndSelect('session.discounts', 'discount')
.leftJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order')
.where('session.id = :sessionId', { sessionId })
@@ -210,11 +210,45 @@ describe('OrderService', () => {
accessToken: 'plain-token',
checkoutInvoice: expect.objectContaining({
statusLabel: 'Payment confirmed',
paymentLabel: 'XMR',
expectedTotalCrypto: '0.10000000'
})
})
);
});
it('formats bitcoin checkout invoice amounts for the admin order view', async () => {
const storedOrder = {
...buildStoredOrder(),
checkoutInvoice: {
id: 'invoice-1',
fiatCurrency: 'USD',
paymentMethod: PaymentMethod.Btc,
expectedTotalAtomic: '100000000',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
btcDetails: { fiatPerBtcAtCreation: 60_000, requiredConfirmations: 1 },
moneroDetails: null,
payments: [{ id: 'pay-1', amountAtomic: '100000000', confirmations: 1, txHash: 'tx-1' }],
reason: InvoiceReason.Checkout
}
} as unknown as Order;
orderDetailQueryBuilder.getOne.mockResolvedValue(storedOrder);
const result = await service.findById('order-1');
expect(result.checkoutInvoice).toEqual(
expect.objectContaining({
paymentLabel: 'BTC',
expectedTotalCrypto: '1.00000000',
payments: [
expect.objectContaining({
amountCrypto: '1.00000000'
})
]
})
);
});
});
describe('setDeliveryCost', () => {
@@ -222,7 +256,7 @@ describe('OrderService', () => {
({
id: 'order-1',
lines: [{ deliveryMode: DeliveryMode.Manual }],
checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD' }
checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD', paymentMethod: PaymentMethod.Xmr }
}) as Order;
it('throws when the order cannot be quoted', async () => {
@@ -300,6 +334,26 @@ describe('OrderService', () => {
});
expect(result).toBe(orderExtended);
});
it('uses the checkout invoice payment method for shipping invoices', async () => {
orderRepo.findOne.mockResolvedValue({
...buildQuotableOrder(),
checkoutInvoice: {
id: 'checkout-invoice-1',
fiatCurrency: 'USD',
paymentMethod: PaymentMethod.Btc
}
});
await service.setDeliveryCost('order-1', { deliveryCost: 12.5 });
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Btc,
reason: InvoiceReason.Shipping,
contextId: 'order-1',
amountFiat: 12.5
});
});
});
describe('fulfillManualLine', () => {
@@ -8,14 +8,15 @@ import { formatInvoicePaymentConfirmationStatus } from '../../../utils/invoice/f
import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage';
import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations';
import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState';
import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
import type { CryptoAtomicConverter } from '../../../types/CryptoAtomicConverter';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { resolveAtomicToCryptoConverter } from '../../../utils/payment/resolveAtomicToCryptoConverter';
import type { Invoice } from '../../payment/entities/Invoice';
import type { InvoicePayment } from '../../payment/entities/InvoicePayment';
import type { InvoiceExtended } from '../../payment/types/InvoiceExtended';
import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto';
import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto';
@@ -99,9 +100,11 @@ export class OrderService {
'checkoutInvoice',
'checkoutInvoice.payments',
'checkoutInvoice.moneroDetails',
'checkoutInvoice.btcDetails',
'shippingInvoice',
'shippingInvoice.payments',
'shippingInvoice.moneroDetails',
'shippingInvoice.btcDetails',
'lines',
'lines.manualFulfillment',
'discounts',
@@ -181,24 +184,31 @@ export class OrderService {
const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null;
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
const convertAtomicToCrypto = resolveAtomicToCryptoConverter(invoice.paymentMethod);
const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
const payments = (invoice.payments ?? []).map(payment =>
this.toInvoicePaymentExtended(payment, requiredConfirmations)
this.toInvoicePaymentExtended(payment, requiredConfirmations, convertAtomicToCrypto)
);
return {
...invoice,
statusLabel,
paymentLabel: paymentMethodLabel[invoice.paymentMethod],
expectedTotalCrypto,
payments
};
}
private toInvoicePaymentExtended(payment: InvoicePayment, requiredConfirmations: number): InvoicePaymentExtended {
private toInvoicePaymentExtended(
payment: InvoicePayment,
requiredConfirmations: number,
convertAtomicToCrypto: CryptoAtomicConverter
): InvoicePaymentExtended {
const isConfirmed = payment.confirmations >= requiredConfirmations;
const amountCrypto = convertXmrAtomicToXmr(payment.amountAtomic);
const amountCrypto = convertAtomicToCrypto(payment.amountAtomic);
const confirmationsLabel = formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations,
@@ -244,7 +254,7 @@ export class OrderService {
}
const shippingInvoice = await this.invoiceService.issueInvoice({
paymentMethod: PaymentMethod.Xmr,
paymentMethod: order.checkoutInvoice.paymentMethod,
reason: InvoiceReason.Shipping,
contextId: orderId,
amountFiat: deliveryCost
+4 -1
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice';
import { InvoiceBtcDetails } from './entities/InvoiceBtcDetails';
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
import { InvoicePayment } from './entities/InvoicePayment';
import { InvoicePaymentService } from './services/InvoicePaymentService';
@@ -10,8 +12,9 @@ import { InvoiceService } from './services/InvoiceService';
@Module({
imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule,
BitcoinWalletModule,
ExchangeRateModule
],
providers: [InvoicePaymentService, InvoiceService],
@@ -3,6 +3,7 @@ import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer'
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
import { PaymentMethod } from '../types/PaymentMethod';
import { InvoiceReason } from '../types/InvoiceReason';
import { InvoiceBtcDetails } from './InvoiceBtcDetails';
import { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
import { InvoicePayment } from './InvoicePayment';
@@ -43,6 +44,9 @@ export class Invoice {
@OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true })
moneroDetails: InvoiceMoneroDetails | null;
@OneToOne(() => InvoiceBtcDetails, btcDetails => btcDetails.invoice, { cascade: true })
btcDetails: InvoiceBtcDetails | null;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,24 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
import { Invoice } from './Invoice';
@Entity('invoice_btc_details')
export class InvoiceBtcDetails {
@PrimaryGeneratedColumn('uuid')
id: string;
@OneToOne(() => Invoice, invoice => invoice.btcDetails, { onDelete: 'CASCADE' })
@JoinColumn()
invoice: Invoice;
@Column({
type: 'numeric',
precision: 12,
scale: 2,
transformer: new ColumnNumericTransformer()
})
fiatPerBtcAtCreation: number;
@Column({ type: 'int' })
requiredConfirmations: number;
}
@@ -1,17 +1,22 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment';
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod';
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
import { InvoicePaymentService } from './InvoicePaymentService';
const minIncomingAtomic = '100000000';
const minXmrIncomingAtomic = '100000000';
const minBtcIncomingAtomic = '7';
const xmrBelowMinIncomingAtomic = String(BigInt(minXmrIncomingAtomic) - 1n);
const btcBelowMinIncomingAtomic = String(BigInt(minBtcIncomingAtomic) - 1n);
const buildTransfer = (
const buildXmrTransfer = (
overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
): MoneroWalletRpcIncomingTransfer => ({
txHash: 'tx-hash-1',
@@ -21,7 +26,14 @@ const buildTransfer = (
...overrides
});
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
const buildBtcTransfer = (overrides: Partial<InvoiceIncomingTransfer> = {}): InvoiceIncomingTransfer => ({
txHash: 'tx-hash-1',
amountAtomic: '200000000',
confirmations: 1,
...overrides
});
const buildXmrInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
id: 'invoice-1',
paymentMethod: PaymentMethod.Xmr,
@@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
...overrides
}) as Invoice;
const buildBtcInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
id: 'invoice-btc-1',
paymentMethod: PaymentMethod.Btc,
paymentAddress: 'bc1qtest',
btcDetails: { requiredConfirmations: 1 },
payments: [],
...overrides
}) as Invoice;
describe('InvoicePaymentService', () => {
let service: InvoicePaymentServiceTest;
let invoiceRepo: {
@@ -41,7 +63,11 @@ describe('InvoicePaymentService', () => {
andWhere: jest.Mock;
getMany: jest.Mock;
};
let walletRpcClient: {
let moneroWalletRpcClient: {
getIncomingTransfers: jest.Mock;
};
let bitcoinWalletRpcClient: {
getInfo: jest.Mock;
getIncomingTransfers: jest.Mock;
};
let configService: {
@@ -61,6 +87,7 @@ describe('InvoicePaymentService', () => {
};
let paymentRepo: {
update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock;
};
let insertQueryBuilder: {
@@ -107,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = {
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
};
@@ -130,14 +158,20 @@ describe('InvoicePaymentService', () => {
)
};
walletRpcClient = {
moneroWalletRpcClient = {
getIncomingTransfers: jest.fn().mockResolvedValue([])
};
bitcoinWalletRpcClient = {
getInfo: jest.fn().mockResolvedValue({ blockchain_height: 900_000 }),
getIncomingTransfers: jest.fn().mockResolvedValue([])
};
configService = {
get: jest.fn().mockReturnValue({
minByMethod: {
[PaymentMethod.Xmr]: minIncomingAtomic
[PaymentMethod.Xmr]: minXmrIncomingAtomic,
[PaymentMethod.Btc]: minBtcIncomingAtomic
}
})
};
@@ -145,7 +179,8 @@ describe('InvoicePaymentService', () => {
service = new InvoicePaymentService(
invoiceRepo as unknown as Repository<Invoice>,
dataSource as unknown as DataSource,
walletRpcClient as unknown as MoneroWalletRpcClient,
moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
configService as unknown as ConfigService
) as unknown as InvoicePaymentServiceTest;
@@ -157,87 +192,184 @@ describe('InvoicePaymentService', () => {
errorLogSpy.mockRestore();
});
describe('pollInvoices', () => {
describe('pollMoneroInvoices', () => {
it('returns early when there are no open invoices', async () => {
pollQueryBuilder.getMany.mockResolvedValue([]);
await service.pollInvoices();
await service.pollMoneroInvoices();
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(moneroWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('returns early when incoming transfers cannot be fetched', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]);
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice()]);
moneroWalletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
await service.pollInvoices();
await service.pollMoneroInvoices();
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('routes transfers to each invoice by subaddress index', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({
buildXmrInvoice({
id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}),
buildInvoice({
buildXmrInvoice({
id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
})
]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
buildXmrTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
]);
await service.pollInvoices();
await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 })
expect.objectContaining({ txHash: 'tx-a' })
]);
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 })
expect.objectContaining({ txHash: 'tx-b' })
]);
});
it('continues processing other invoices when one invoice fails', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ id: 'invoice-1' }),
buildInvoice({ id: 'invoice-2' })
buildXmrInvoice({ id: 'invoice-1' }),
buildXmrInvoice({ id: 'invoice-2' })
]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
processInvoiceSpy.mockRestore();
processInvoiceSpy = jest
.spyOn(service, 'processInvoice')
.mockRejectedValueOnce(new Error('invoice-1 failed'))
.mockResolvedValueOnce(undefined);
await service.pollInvoices();
await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({
buildXmrInvoice({
id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}),
buildInvoice({
buildXmrInvoice({
id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
})
]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
await service.pollInvoices();
await service.pollMoneroInvoices();
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
it('processes invoices with no matching transfers as an empty batch', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice({ id: 'invoice-1' })]);
moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-other' })
]);
await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-1', []);
});
});
describe('pollBitcoinInvoices', () => {
it('returns early when there are no open invoices', async () => {
pollQueryBuilder.getMany.mockResolvedValue([]);
await service.pollBitcoinInvoices();
expect(bitcoinWalletRpcClient.getInfo).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('returns early when wallet info cannot be fetched', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice()]);
bitcoinWalletRpcClient.getInfo.mockRejectedValue(new Error('rpc down'));
await service.pollBitcoinInvoices();
expect(bitcoinWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('fetches transfers per invoice using the current block height', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }),
buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' })
]);
bitcoinWalletRpcClient.getIncomingTransfers
.mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-a' })])
.mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]);
await service.pollBitcoinInvoices();
expect(bitcoinWalletRpcClient.getInfo).toHaveBeenCalled();
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(1, 'bc1qone', 900_000);
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(2, 'bc1qtwo', 900_000);
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-btc-1', [
expect.objectContaining({ txHash: 'tx-a' })
]);
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-btc-2', [
expect.objectContaining({ txHash: 'tx-b' })
]);
});
it('continues processing other invoices when one invoice fails', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildBtcInvoice({ id: 'invoice-btc-1' }),
buildBtcInvoice({ id: 'invoice-btc-2' })
]);
bitcoinWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildBtcTransfer()]);
processInvoiceSpy.mockRestore();
processInvoiceSpy = jest
.spyOn(service, 'processInvoice')
.mockRejectedValueOnce(new Error('invoice-btc-1 failed'))
.mockResolvedValueOnce(undefined);
await service.pollBitcoinInvoices();
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
it('continues processing other invoices when incoming transfers cannot be fetched for one invoice', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }),
buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' })
]);
bitcoinWalletRpcClient.getIncomingTransfers
.mockRejectedValueOnce(new Error('rpc down'))
.mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]);
await service.pollBitcoinInvoices();
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledTimes(2);
expect(processInvoiceSpy).toHaveBeenCalledTimes(1);
expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-btc-2', [
expect.objectContaining({ txHash: 'tx-b' })
]);
});
it('passes null block height when wallet info has no blockchain height', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice({ paymentAddress: 'bc1qtest' })]);
bitcoinWalletRpcClient.getInfo.mockResolvedValue({ server_height: 900_000 });
await service.pollBitcoinInvoices();
expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith('bc1qtest', null);
});
});
describe('processInvoice', () => {
@@ -248,39 +380,70 @@ describe('InvoicePaymentService', () => {
it('does nothing when the invoice is missing inside the transaction', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
await service.processInvoice('invoice-1', [buildTransfer()]);
await service.processInvoice('invoice-1', [buildXmrTransfer()]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('does not mutate payments when there are no transfers and no existing payments', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', []);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.delete).not.toHaveBeenCalled();
});
it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-ghost',
txHash: 'ghost',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', []);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost');
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('skips transfers below the configured minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', [
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' })
buildXmrTransfer({ amountAtomic: xmrBelowMinIncomingAtomic, txHash: 'dust-tx' })
]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', [
buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 })
buildXmrTransfer({ txHash: 'min-tx', amountAtomic: minXmrIncomingAtomic, confirmations: 1 })
]);
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' },
txHash: 'min-tx',
amountAtomic: minIncomingAtomic,
amountAtomic: minXmrIncomingAtomic,
confirmations: 1
});
});
it('processes a mixed batch of dust, new, and existing transfers', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({
buildXmrInvoice({
payments: [
{
id: 'payment-1',
@@ -293,9 +456,9 @@ describe('InvoicePaymentService', () => {
);
await service.processInvoice('invoice-1', [
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }),
buildTransfer({ txHash: 'known-tx', confirmations: 4 }),
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
buildXmrTransfer({ txHash: 'dust-tx', amountAtomic: xmrBelowMinIncomingAtomic }),
buildXmrTransfer({ txHash: 'known-tx', confirmations: 4 }),
buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
@@ -310,8 +473,8 @@ describe('InvoicePaymentService', () => {
it('inserts a new payment for transfers at or above the minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
const transfer = buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
await service.processInvoice('invoice-1', [transfer]);
@@ -327,7 +490,7 @@ describe('InvoicePaymentService', () => {
it('updates confirmations for an existing payment when they change', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({
buildXmrInvoice({
payments: [
{
id: 'payment-1',
@@ -339,7 +502,7 @@ describe('InvoicePaymentService', () => {
})
);
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]);
await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 5 })]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
@@ -347,7 +510,7 @@ describe('InvoicePaymentService', () => {
it('does not update an existing payment when confirmations are unchanged', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({
buildXmrInvoice({
payments: [
{
id: 'payment-1',
@@ -359,10 +522,64 @@ describe('InvoicePaymentService', () => {
})
);
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]);
await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 3 })]);
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('skips transfers below the configured minimum for bitcoin invoices', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice());
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ amountAtomic: btcBelowMinIncomingAtomic, txHash: 'dust-tx' })
]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('inserts a payment when the transfer amount equals the configured minimum for bitcoin invoices', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice());
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ txHash: 'min-tx', amountAtomic: minBtcIncomingAtomic, confirmations: 1 })
]);
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-btc-1' },
txHash: 'min-tx',
amountAtomic: minBtcIncomingAtomic,
confirmations: 1
});
});
it('removes unconfirmed payments that are no longer reported', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-original',
txHash: 'original',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment,
{
id: 'payment-replacement',
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 })
]);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original');
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});
});
@@ -7,11 +7,13 @@ import type { Config } from '../../../types/Config';
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment';
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod';
import { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
@Injectable()
export class InvoicePaymentService {
@@ -21,12 +23,17 @@ export class InvoicePaymentService {
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>,
private readonly dataSource: DataSource,
private readonly walletRpcClient: MoneroWalletRpcClient,
private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly configService: ConfigService
) {}
@Cron(CronExpression.EVERY_10_SECONDS)
private async pollInvoices(): Promise<void> {
await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]);
}
private async pollMoneroInvoices(): Promise<void> {
const now = new Date();
const invoices = await this.invoiceRepo
@@ -55,7 +62,7 @@ export class InvoicePaymentService {
let transfers: MoneroWalletRpcIncomingTransfer[];
try {
transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices);
transfers = await this.moneroWalletRpcClient.getIncomingTransfers(subaddrIndices);
} catch (error) {
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`);
@@ -77,9 +84,57 @@ export class InvoicePaymentService {
}
}
private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
private async pollBitcoinInvoices(): Promise<void> {
const now = new Date();
const invoices = await this.invoiceRepo
.createQueryBuilder('invoice')
.innerJoinAndSelect('invoice.btcDetails', 'btcDetails')
.where('invoice.paymentMethod = :paymentMethod', { paymentMethod: PaymentMethod.Btc })
.andWhere(
new Brackets(qb => {
qb.where('invoice.expiresAt > :now', { now }).orWhere(
`"btcDetails"."requiredConfirmations" > 0 AND EXISTS (
SELECT 1 FROM invoice_payments pollPayment
WHERE pollPayment."invoiceId" = invoice.id
AND pollPayment.confirmations < "btcDetails"."requiredConfirmations"
)`
);
})
)
.getMany();
if (invoices.length === 0) {
return;
}
let blockHeight: number | null;
try {
const info = await this.bitcoinWalletRpcClient.getInfo();
blockHeight = info.blockchain_height ?? null;
} catch (error) {
this.logger.error(`Failed to fetch Bitcoin wallet info: ${getErrorMessage(error)}`);
return;
}
for (const invoice of invoices) {
try {
const transfers = await this.bitcoinWalletRpcClient.getIncomingTransfers(
invoice.paymentAddress,
blockHeight
);
await this.processInvoice(invoice.id, transfers);
} catch (error) {
this.logger.error(`Failed to process invoice ${invoice.id}: ${getErrorMessage(error)}`);
}
}
}
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment);
@@ -95,6 +150,19 @@ export class InvoicePaymentService {
return;
}
await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
});
}
private async upsertIncomingPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
@@ -117,7 +185,7 @@ export class InvoicePaymentService {
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoiceId },
invoice: { id: invoice.id },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
@@ -125,6 +193,19 @@ export class InvoicePaymentService {
.orIgnore()
.execute();
}
});
}
private async pruneAbsentUnconfirmedPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash));
for (const payment of invoice.payments ?? []) {
if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) {
await paymentRepo.delete(payment.id);
}
}
}
}
@@ -1,6 +1,7 @@
import { Logger, ServiceUnavailableException } from '@nestjs/common';
import { Logger, InternalServerErrorException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
@@ -19,7 +20,10 @@ describe('InvoiceService', () => {
let configService: {
get: jest.Mock;
};
let walletRpcClient: {
let moneroWalletRpcClient: {
createAddress: jest.Mock;
};
let bitcoinWalletRpcClient: {
createAddress: jest.Mock;
};
let exchangeRateService: {
@@ -45,6 +49,10 @@ describe('InvoiceService', () => {
return { confirmationTiers };
}
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
}
@@ -53,13 +61,17 @@ describe('InvoiceService', () => {
})
};
walletRpcClient = {
moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample',
address_index: 12
})
};
bitcoinWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
};
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
@@ -67,7 +79,8 @@ describe('InvoiceService', () => {
service = new InvoiceService(
invoiceRepo as unknown as Repository<Invoice>,
configService as unknown as ConfigService,
walletRpcClient as unknown as MoneroWalletRpcClient,
moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
exchangeRateService as unknown as ExchangeRateService
);
});
@@ -76,9 +89,9 @@ describe('InvoiceService', () => {
errorLogSpy.mockRestore();
});
const issueCheckoutInvoice = () =>
const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) =>
service.issueInvoice({
paymentMethod: PaymentMethod.Xmr,
paymentMethod,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 15
@@ -91,11 +104,11 @@ describe('InvoiceService', () => {
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
);
expect(walletRpcClient.createAddress).not.toHaveBeenCalled();
expect(moneroWalletRpcClient.createAddress).not.toHaveBeenCalled();
});
it('throws and logs when wallet address allocation fails for checkout invoices', async () => {
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
@@ -108,7 +121,7 @@ describe('InvoiceService', () => {
it('creates a checkout invoice with converted totals and monero details', async () => {
const invoice = await issueCheckoutInvoice();
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
reason: InvoiceReason.Checkout,
@@ -145,6 +158,10 @@ describe('InvoiceService', () => {
};
}
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
}
@@ -185,7 +202,7 @@ describe('InvoiceService', () => {
);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(
service.issueInvoice({
@@ -200,7 +217,7 @@ describe('InvoiceService', () => {
)
);
walletRpcClient.createAddress.mockResolvedValue({
moneroWalletRpcClient.createAddress.mockResolvedValue({
address: '4ShippingPaymentAddressExample',
address_index: 3
});
@@ -212,6 +229,87 @@ describe('InvoiceService', () => {
amountFiat: 5
});
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
});
it('throws when the live BTC rate is unavailable for checkout invoices', async () => {
exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
(method: PaymentMethod) => (method === PaymentMethod.Btc ? null : 150)
);
await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow(
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
);
expect(bitcoinWalletRpcClient.createAddress).not.toHaveBeenCalled();
});
it('throws and logs when Bitcoin address allocation fails for checkout invoices', async () => {
exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
(method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150)
);
bitcoinWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow(
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
);
expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Bitcoin payment address'));
expect(invoiceRepo.save).not.toHaveBeenCalled();
});
it('creates a checkout invoice with converted totals and bitcoin details', async () => {
exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
(method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150)
);
const invoice = await issueCheckoutInvoice(PaymentMethod.Btc);
expect(bitcoinWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
reason: InvoiceReason.Checkout,
paymentMethod: PaymentMethod.Btc,
amountFiat: 15,
fiatCurrency: 'USD',
paymentAddress: 'bc1qtestpaymentaddress',
expectedTotalAtomic: '25000',
expiresAt: expect.any(Date),
btcDetails: {
fiatPerBtcAtCreation: 60_000,
requiredConfirmations: 1
}
})
);
expect(invoiceRepo.save).toHaveBeenCalled();
expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 }));
});
it('uses BTC-specific shipping rate messages', async () => {
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect(
service.issueInvoice({
paymentMethod: PaymentMethod.Btc,
reason: InvoiceReason.Shipping,
contextId: 'order-1',
amountFiat: 5
})
).rejects.toThrow(
new ServiceUnavailableException(
"We can't quote shipping in BTC right now. Please try again in a few minutes."
)
);
});
it('throws for unsupported payment methods', async () => {
await expect(
service.issueInvoice({
paymentMethod: 'eth' as PaymentMethod,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 15
})
).rejects.toThrow(new InternalServerErrorException('Unsupported payment method: eth'));
});
});
@@ -1,13 +1,16 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
import dayjs from '../../../plugins/dayjs';
import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { convertFiatToBtc } from '../../../utils/bitcoin/convertFiatToBtc';
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations';
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
@@ -24,14 +27,19 @@ export class InvoiceService {
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>,
private readonly configService: ConfigService,
private readonly walletRpcClient: MoneroWalletRpcClient,
private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly exchangeRateService: ExchangeRateService
) {}
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> {
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
switch (data.paymentMethod) {
case PaymentMethod.Xmr:
return this.issueXmrInvoice(data);
case PaymentMethod.Btc:
return this.issueBtcInvoice(data);
default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(data.paymentMethod)}`);
}
}
@@ -41,7 +49,8 @@ export class InvoiceService {
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
reason,
contextId
contextId,
PaymentMethod.Xmr
);
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
@@ -54,7 +63,7 @@ export class InvoiceService {
let paymentAddressIndex: number;
try {
const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel);
const { address, address_index } = await this.moneroWalletRpcClient.createAddress(addressLabel);
paymentAddress = address;
paymentAddressIndex = address_index;
@@ -89,8 +98,63 @@ export class InvoiceService {
return this.invoiceRepo.save(invoice);
}
private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData {
private async issueBtcInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise<Invoice> {
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const { confirmationTiers } = this.configService.get('shopSettings.bitcoin') as Config['shopSettings']['bitcoin'];
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
reason,
contextId,
PaymentMethod.Btc
);
const fiatPerBtc = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Btc);
if (fiatPerBtc === null) {
throw new ServiceUnavailableException(rateUnavailableMessage);
}
let paymentAddress: string;
try {
paymentAddress = await this.bitcoinWalletRpcClient.createAddress(addressLabel);
} catch (error) {
this.logger.error(`Failed to allocate Bitcoin payment address: ${getErrorMessage(error)}`);
throw new ServiceUnavailableException(unavailableMessage);
}
const requiredConfirmations = resolveMinConfirmations(amountFiat, confirmationTiers);
const expiresAt = dayjs().add(validityMs, 'millisecond').toDate();
const expectedTotalBtc = convertFiatToBtc(amountFiat, fiatPerBtc);
const expectedTotalAtomic = convertBtcToBtcAtomic(expectedTotalBtc);
const invoice = this.invoiceRepo.create({
reason,
paymentMethod: PaymentMethod.Btc,
amountFiat,
fiatCurrency: shopFiatCurrency,
expiresAt,
paymentAddress,
expectedTotalAtomic,
btcDetails: {
fiatPerBtcAtCreation: fiatPerBtc,
requiredConfirmations
}
});
return this.invoiceRepo.save(invoice);
}
private resolveReasonData(
reason: InvoiceReason,
contextId: string,
paymentMethod: PaymentMethod
): InvoiceReasonData {
const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
const cryptoLabel = paymentMethod === PaymentMethod.Btc ? 'BTC' : 'XMR';
switch (reason) {
case InvoiceReason.Checkout:
@@ -105,8 +169,7 @@ export class InvoiceService {
addressLabel: `order-shipping - ${contextId}`,
validityMs: shippingPaymentValidityMs,
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
rateUnavailableMessage:
"We can't quote shipping in XMR right now. Please try again in a few minutes."
rateUnavailableMessage: `We can't quote shipping in ${cryptoLabel} right now. Please try again in a few minutes.`
};
}
}
@@ -4,6 +4,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
export type InvoiceExtended = Omit<Invoice, 'payments'> & {
statusLabel: InvoiceStatusLabel | null;
paymentLabel: string;
expectedTotalCrypto: string;
payments: InvoicePaymentExtended[];
};
@@ -0,0 +1,5 @@
export type InvoiceIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
};
@@ -1,6 +1,8 @@
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import type { InvoiceIncomingTransfer } from './InvoiceIncomingTransfer';
export type InvoicePaymentServiceTest = {
pollInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise<void>;
pollMoneroInvoices: () => Promise<void>;
pollBitcoinInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise<void>;
};
@@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard';
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
@@ -43,6 +44,11 @@ export class ShopSettingsController {
return this.shopSettingsService.updateNotifications(dto);
}
@Patch('/blog-enabled')
updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) {
return this.shopSettingsService.updateBlogEnabled(dto);
}
@Post('/simplex-connect')
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
@@ -0,0 +1,7 @@
import { IsBoolean, IsNotEmpty } from 'class-validator';
export class UpdateBlogEnabledDto {
@IsNotEmpty()
@IsBoolean()
blogEnabled: boolean;
}
@@ -32,6 +32,9 @@ export class ShopSettings {
@Column({ type: 'integer', nullable: true })
simplexNotificationContactId: number | null;
@Column({ type: 'boolean', default: false })
blogEnabled: boolean;
@CreateDateColumn()
createdAt: Date;
@@ -25,6 +25,7 @@ describe('ShopSettingsService', () => {
notifyOnNewOrder: false,
notifyOnOrderMessage: false,
simplexNotificationContactId: null,
blogEnabled: false,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z')
} as ShopSettings;
@@ -67,10 +68,27 @@ describe('ShopSettingsService', () => {
logoUrl: '/uploads/public/shop/logo.png',
faviconUrl: null,
simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days'
shippingNote: 'Ships in 3 days',
blogEnabled: false
});
});
it('updates blog enabled flag', async () => {
await service.updateBlogEnabled({ blogEnabled: true });
expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { blogEnabled: true });
});
it('includes blog enabled in shop settings view', async () => {
shopSettingsRepo.find.mockResolvedValue([{ ...settings, blogEnabled: true }]);
await expect(service.getView()).resolves.toEqual(
expect.objectContaining({
blogEnabled: true
})
);
});
it('trims simplex links on update', async () => {
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
@@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf
import { Config } from '../../../types/Config';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
@@ -43,7 +44,8 @@ export class ShopSettingsService {
logoUrl,
faviconUrl,
simplexLink: settings.simplexLink,
shippingNote: settings.shippingNote
shippingNote: settings.shippingNote,
blogEnabled: settings.blogEnabled
};
}
@@ -93,6 +95,14 @@ export class ShopSettingsService {
return this.getView();
}
async updateBlogEnabled({ blogEnabled }: UpdateBlogEnabledDto): Promise<ShopSettingsView> {
const settings = await this.findSettings();
await this.shopSettingsRepo.update(settings.id, { blogEnabled });
return this.getView();
}
async uploadLogo(filename: string): Promise<ShopSettingsView> {
const settings = await this.findSettings();
@@ -191,12 +201,15 @@ export class ShopSettingsService {
notifyOnNewOrder,
notifyOnOrderMessage,
simplexNotificationContactId,
blogEnabled,
createdAt,
updatedAt
}: ShopSettings): ShopSettingsView {
const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote });
const { shopName, shopFiatCurrency, monero } = this.configService.get('shopSettings') as Config['shopSettings'];
const { shopName, shopFiatCurrency, monero, bitcoin } = this.configService.get(
'shopSettings'
) as Config['shopSettings'];
const logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null;
const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null;
@@ -208,6 +221,7 @@ export class ShopSettingsService {
shopName,
shopFiatCurrency,
monero,
bitcoin,
logoUrl,
faviconUrl,
simplexLink,
@@ -217,6 +231,7 @@ export class ShopSettingsService {
notifyOnNewOrder,
notifyOnOrderMessage,
simplexNotificationConnected: simplexNotificationContactId !== null,
blogEnabled,
isSetupComplete,
setupChecklist,
createdAt,
@@ -0,0 +1,5 @@
import { ConfirmationTier } from '../../../types/ConfirmationTier';
export interface ShopSettingsBitcoinView {
confirmationTiers: ConfirmationTier[];
}
@@ -1,5 +1,5 @@
import { MoneroConfirmationTier } from '../../../types/MoneroConfirmationTier';
import { ConfirmationTier } from '../../../types/ConfirmationTier';
export interface ShopSettingsMoneroView {
confirmationTiers: MoneroConfirmationTier[];
confirmationTiers: ConfirmationTier[];
}
@@ -1,12 +1,14 @@
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
import { SetupChecklist } from './SetupChecklist';
import { ShopSettingsMoneroView } from './ShopSettingsMoneroView';
import { ShopSettingsBitcoinView } from './ShopSettingsBitcoinView';
export interface ShopSettingsView {
id: string | null;
shopName: string;
shopFiatCurrency: ShopFiatCurrency;
monero: ShopSettingsMoneroView;
bitcoin: ShopSettingsBitcoinView;
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
@@ -16,6 +18,7 @@ export interface ShopSettingsView {
notifyOnNewOrder: boolean;
notifyOnOrderMessage: boolean;
simplexNotificationConnected: boolean;
blogEnabled: boolean;
isSetupComplete: boolean;
setupChecklist: SetupChecklist;
createdAt: Date | null;
@@ -3,4 +3,5 @@ export interface StorefrontBranding {
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
blogEnabled: boolean;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BlogModule } from '../blog/BlogModule';
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontBlogController } from './controllers/StorefrontBlogController';
import { StorefrontBlogPostsService } from './services/StorefrontBlogPostsService';
import { StorefrontBlogViewService } from './services/StorefrontBlogViewService';
@Module({
imports: [BlogModule, ShopSettingsModule, StorefrontCoreModule],
controllers: [StorefrontBlogController],
providers: [StorefrontBlogPostsService, StorefrontBlogViewService]
})
export class StorefrontBlogModule {}
@@ -0,0 +1,69 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Req, Res, UseFilters } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService';
import { StorefrontBlogPostsService } from '../services/StorefrontBlogPostsService';
import { StorefrontBlogViewService } from '../services/StorefrontBlogViewService';
@Controller()
@UseFilters(StorefrontExceptionFilter)
export class StorefrontBlogController {
constructor(
private readonly blogPostsService: StorefrontBlogPostsService,
private readonly blogViewService: StorefrontBlogViewService,
private readonly shopViewService: StorefrontShopViewService,
private readonly shopSettingsService: ShopSettingsService
) {}
@Get('shop/blog')
async blogIndex(@Req() req: Request, @Res() res: Response) {
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
if (!blogEnabled) {
throw new NotFoundException('We could not find that page.');
}
const [posts, shopLocals] = await Promise.all([
this.blogPostsService.listLivePosts(),
this.shopViewService.buildShopRenderLocals(req, res, {
title: 'Blog',
metaDescription: 'News and updates from {shopName}.'
})
]);
const postsView = this.blogViewService.toPostsIndexView(posts);
return res.render('blog-index', {
posts: postsView,
...shopLocals
});
}
@Get('shop/blog/:id')
async blogDetail(@Param('id', ParseUUIDPipe) id: string, @Req() req: Request, @Res() res: Response) {
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
if (!blogEnabled) {
throw new NotFoundException('We could not find that page.');
}
const post = await this.blogPostsService.getLivePost(id);
const postView = this.blogViewService.toPostDetailView(post);
const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, {
title: post.title,
metaDescription: `Read ${post.title} at {shopName}.`,
ogType: 'article',
ogImageUrl: postView.bannerUrl ?? postView.thumbnailUrl
});
return res.render('blog-detail', {
post: postView,
backHref: '/shop/blog',
backLabel: 'Blog',
...shopLocals
});
}
}
@@ -0,0 +1,42 @@
import { NotFoundException } from '@nestjs/common';
import type { Repository } from 'typeorm';
import { BlogPost } from '../../blog/entities/BlogPost';
import { StorefrontBlogPostsService } from './StorefrontBlogPostsService';
describe('StorefrontBlogPostsService', () => {
let service: StorefrontBlogPostsService;
let blogPostRepo: {
find: jest.Mock;
findOne: jest.Mock;
};
beforeEach(() => {
blogPostRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null)
};
service = new StorefrontBlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
});
it('lists live posts ordered by createdAt descending', async () => {
await service.listLivePosts();
expect(blogPostRepo.find).toHaveBeenCalledWith({
where: { isDraft: false },
order: { createdAt: 'DESC' }
});
});
it('returns a live post by id', async () => {
const post = { id: 'post-1', title: 'Hello', isDraft: false } as BlogPost;
blogPostRepo.findOne.mockResolvedValue(post);
await expect(service.getLivePost('post-1')).resolves.toBe(post);
});
it('throws when a draft or missing post is requested', async () => {
await expect(service.getLivePost('missing-id')).rejects.toThrow(NotFoundException);
});
});
@@ -0,0 +1,31 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BlogPost } from '../../blog/entities/BlogPost';
@Injectable()
export class StorefrontBlogPostsService {
constructor(
@InjectRepository(BlogPost)
private readonly blogPostRepo: Repository<BlogPost>
) {}
async listLivePosts(): Promise<BlogPost[]> {
return this.blogPostRepo.find({
where: { isDraft: false },
order: { createdAt: 'DESC' }
});
}
async getLivePost(id: string): Promise<BlogPost> {
const post = await this.blogPostRepo.findOne({
where: { id, isDraft: false }
});
if (!post) {
throw new NotFoundException('We could not find that blog post.');
}
return post;
}
}
@@ -0,0 +1,34 @@
import { BlogPost } from '../../blog/entities/BlogPost';
import { StorefrontBlogViewService } from './StorefrontBlogViewService';
describe('StorefrontBlogViewService', () => {
let service: StorefrontBlogViewService;
beforeEach(() => {
service = new StorefrontBlogViewService();
});
it('maps posts to card views with image urls', () => {
const views = service.toPostsIndexView([
{
id: 'post-1',
title: 'Hello',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: null,
contentHtml: '',
isDraft: false,
createdAt: new Date(),
updatedAt: new Date()
} as BlogPost
]);
expect(views).toEqual([
{
id: 'post-1',
title: 'Hello',
thumbnailUrl: '/uploads/public/blog/images/thumb.webp',
detailHref: '/shop/blog/post-1'
}
]);
});
});
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { getBlogImagePublicUrl } from '../../../config/uploadPaths';
import { BlogPost } from '../../blog/entities/BlogPost';
import type { StorefrontBlogPostCardView } from '../types/StorefrontBlogPostCardView';
import type { StorefrontBlogPostDetailView } from '../types/StorefrontBlogPostDetailView';
@Injectable()
export class StorefrontBlogViewService {
toPostsIndexView(posts: BlogPost[]): StorefrontBlogPostCardView[] {
return posts.map(post => ({
id: post.id,
title: post.title,
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
detailHref: `/shop/blog/${post.id}`
}));
}
toPostDetailView(post: BlogPost): StorefrontBlogPostDetailView {
return {
id: post.id,
title: post.title,
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null,
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
contentHtml: post.contentHtml
};
}
}
@@ -0,0 +1,6 @@
export interface StorefrontBlogPostCardView {
id: string;
title: string;
thumbnailUrl: string | null;
detailHref: string;
}
@@ -0,0 +1,7 @@
export interface StorefrontBlogPostDetailView {
id: string;
title: string;
bannerUrl: string | null;
thumbnailUrl: string | null;
contentHtml: string;
}
@@ -3,14 +3,13 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
import { ProductsModule } from '../product/ProductsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontCartController } from './controllers/StorefrontCartController';
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
import { StorefrontCartService } from './services/StorefrontCartService';
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
@Module({
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule],
controllers: [StorefrontCartController],
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
exports: [StorefrontCartService]
@@ -1,7 +1,11 @@
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { throttleProfiles } from '../../../config/throttleProfiles';
import type { Config } from '../../../types/Config';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
@@ -33,7 +37,8 @@ export class StorefrontCartController {
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
private readonly captchaService: StorefrontCaptchaService,
private readonly captchaCookieService: StorefrontCaptchaCookieService
private readonly captchaCookieService: StorefrontCaptchaCookieService,
private readonly configService: ConfigService
) {}
@Get('shop/cart')
@@ -62,9 +67,18 @@ export class StorefrontCartController {
this.captchaCookieService.setAnswer(req, res, encryptedAnswer);
const { enabledPaymentMethods } = this.configService.get('shopSettings') as Config['shopSettings'];
const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({
paymentMethod,
paymentMethodLabel: paymentMethodLabel[paymentMethod],
iconUrl: paymentMethodIconUrl[paymentMethod]
}));
return res.render('cart-summary', {
...summary,
...shopLocals,
paymentMethods,
captchaSvg
});
}
@@ -75,6 +89,10 @@ export class StorefrontCartController {
@Res() res: Response,
@Body() { variantId, qty }: AddToCartDto
): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const cartMutation = await this.cartService.addToCart(cart, variantId, qty);
@@ -86,7 +104,7 @@ export class StorefrontCartController {
text: 'Added to cart.'
});
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req));
res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
}
@Post('shop/cart/product/update')
@@ -95,6 +113,10 @@ export class StorefrontCartController {
@Res() res: Response,
@Body() { variantId, qty }: UpdateCartQtyDto
): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty);
@@ -111,6 +133,10 @@ export class StorefrontCartController {
@Post('shop/cart/product/remove')
removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const cartMutation = this.cartService.removeFromCart(cart, variantId);
@@ -129,6 +155,10 @@ export class StorefrontCartController {
@Res() res: Response,
@Body() { code }: ApplyDiscountCodeDto
): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
@@ -146,6 +176,10 @@ export class StorefrontCartController {
@Post('shop/cart/discount/remove')
removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
const discountMutation = this.discountService.removeDiscountCode(discountCodes, code);
@@ -175,4 +209,18 @@ export class StorefrontCartController {
this.cartCookieService.setCart(req, res, cartMutation);
}
}
private buildAddToCartRedirectPath(req: Request, variantId: string): string {
const redirectPath = safeInternalShopRedirectPath(req);
const pathname = redirectPath.split('?')[0] ?? redirectPath;
const isProductsIndexPath = pathname === '/' || pathname.startsWith('/shop/categories/');
if (!isProductsIndexPath) {
return redirectPath;
}
const anchor = `product-card-${variantId}`;
return `${redirectPath}#${anchor}`;
}
}
@@ -1,8 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
import type { StorefrontDiscountService } from './StorefrontDiscountService';
import { StorefrontCartService } from './StorefrontCartService';
@@ -30,9 +28,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock;
};
let exchangeRateService: {
getLiveFiatPerCrypto: jest.Mock;
};
let discountService: {
getDiscountStateForCart: jest.Mock;
applyDiscountCode: jest.Mock;
@@ -44,10 +39,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
};
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
discountService = {
getDiscountStateForCart: jest.fn().mockResolvedValue({
discounts: [],
@@ -59,7 +50,6 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService,
exchangeRateService as unknown as ExchangeRateService,
discountService as unknown as StorefrontDiscountService
);
});
@@ -99,8 +89,6 @@ describe('StorefrontCartService', () => {
discounts: [],
cartDiscountTotal: 0,
cartTotalPrice: 0,
cartTotalXmr: null,
fiatPerXmr: null,
hasManualLines: false,
hasAutoLines: false,
cartTotalIssueMessage: null,
@@ -108,23 +96,13 @@ describe('StorefrontCartService', () => {
});
});
it('converts the discounted total to XMR when a live rate is available', async () => {
it('returns cart totals and delivery flags', async () => {
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
expect(summary.cartSubtotal).toBe(10);
expect(summary.cartTotalPrice).toBe(10);
expect(summary.fiatPerXmr).toBe(150);
expect(summary.cartTotalXmr).toBe('0.06666667');
expect(summary.hasAutoLines).toBe(true);
});
it('leaves cartTotalXmr null when no live rate is available', async () => {
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
expect(summary.cartTotalXmr).toBeNull();
expect(summary.fiatPerXmr).toBeNull();
expect(summary.hasIssues).toBe(false);
});
it('flags manual and auto delivery lines separately', async () => {
@@ -7,9 +7,6 @@ import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart';
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
import type { CookieCartExtended } from '../types/CookieCartExtended';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
@@ -23,7 +20,6 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService {
constructor(
private readonly productsService: StorefrontProductsService,
private readonly exchangeRateService: ExchangeRateService,
private readonly discountService: StorefrontDiscountService
) {}
@@ -67,8 +63,6 @@ export class StorefrontCartService {
discounts: [],
cartDiscountTotal: 0,
cartTotalPrice: 0,
cartTotalXmr: null,
fiatPerXmr: null,
hasManualLines: false,
hasAutoLines: false,
cartTotalIssueMessage: null,
@@ -78,8 +72,6 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0);
@@ -92,8 +84,6 @@ export class StorefrontCartService {
cartExtended,
cartSubtotal,
...discountState,
cartTotalXmr,
fiatPerXmr,
hasManualLines,
hasAutoLines,
cartTotalIssueMessage,
@@ -4,8 +4,6 @@ import type { CookieCartExtended } from './CookieCartExtended';
export type CookieCartSummary = {
cartExtended: CookieCartExtended;
cartSubtotal: number;
cartTotalXmr: string | null;
fiatPerXmr: number | null;
hasManualLines: boolean;
hasAutoLines: boolean;
cartTotalIssueMessage: string | null;
@@ -41,7 +41,7 @@ export class StorefrontCheckoutController {
@Post('shop/checkout/pay')
@Throttle(throttleProfiles.checkoutPay)
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise<void> {
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha, paymentMethod }: PayCheckoutDto): Promise<void> {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (sessionId) {
@@ -64,7 +64,7 @@ export class StorefrontCheckoutController {
const summary = await this.cartService.getCartSummary(cart, discountCodes);
const session = await this.checkoutSessionService.createFromCartSummary(summary);
const session = await this.checkoutSessionService.createFromCartSummary(summary, paymentMethod);
this.checkoutSessionCookieService.setSessionId(req, res, session.id);
@@ -1,10 +1,13 @@
import { IsNotEmpty, IsString, Length } from 'class-validator';
import { getAppConfig } from '../../../config';
import { IsEnum, IsIn, IsNotEmpty, IsString, Length } from 'class-validator';
import { getAppConfig, getShopSettingsConfig } from '../../../config';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
const {
captcha: { length: captchaLength }
} = getAppConfig();
const { enabledPaymentMethods } = getShopSettingsConfig();
export class PayCheckoutDto {
@IsNotEmpty()
@IsString()
@@ -12,4 +15,9 @@ export class PayCheckoutDto {
message: `Captcha should be ${captchaLength} characters long`
})
captcha: string;
@IsNotEmpty()
@IsEnum(PaymentMethod)
@IsIn(enabledPaymentMethods, { message: 'Select a valid payment method' })
paymentMethod: PaymentMethod;
}
@@ -25,6 +25,7 @@ export class CheckoutPaymentPollerService {
.createQueryBuilder('session')
.innerJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order')
.where('session.cancelledAt IS NULL')
@@ -38,8 +38,6 @@ const buildSummary = (overrides: Partial<CookieCartSummary> = {}): CookieCartSum
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
cartDiscountTotal: 1,
cartTotalPrice: 9,
cartTotalXmr: '0.06000000',
fiatPerXmr: 150,
hasManualLines: false,
hasAutoLines: true,
cartTotalIssueMessage: null,
@@ -115,15 +113,15 @@ describe('CheckoutSessionService', () => {
describe('createFromCartSummary', () => {
it('rejects an empty cart', async () => {
await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow(
new BadRequestException('Your cart is empty')
);
await expect(
service.createFromCartSummary(buildSummary({ cartExtended: [] }), PaymentMethod.Xmr)
).rejects.toThrow(new BadRequestException('Your cart is empty'));
});
it('rejects carts that still have unresolved issues', async () => {
await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow(
new BadRequestException('Resolve cart issues before paying')
);
await expect(
service.createFromCartSummary(buildSummary({ hasIssues: true }), PaymentMethod.Xmr)
).rejects.toThrow(new BadRequestException('Resolve cart issues before paying'));
});
it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => {
@@ -135,7 +133,7 @@ describe('CheckoutSessionService', () => {
]
});
const session = await service.createFromCartSummary(summary);
const session = await service.createFromCartSummary(summary, PaymentMethod.Xmr);
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Xmr,
@@ -180,7 +178,8 @@ describe('CheckoutSessionService', () => {
await service.createFromCartSummary(
buildSummary({
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
})
}),
PaymentMethod.Xmr
);
expect(discountRepo.create).not.toHaveBeenCalled();
@@ -190,6 +189,17 @@ describe('CheckoutSessionService', () => {
})
);
});
it('issues a bitcoin checkout invoice when that payment method is selected', async () => {
await service.createFromCartSummary(buildSummary(), PaymentMethod.Btc);
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Btc,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 9
});
});
});
describe('findById', () => {
@@ -200,7 +210,14 @@ describe('CheckoutSessionService', () => {
await expect(service.findById('session-1')).resolves.toBe(session);
expect(sessionRepo.findOne).toHaveBeenCalledWith({
where: { id: 'session-1' },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
relations: [
'lines',
'discounts',
'invoice',
'invoice.moneroDetails',
'invoice.btcDetails',
'invoice.payments'
]
});
});
});
@@ -6,7 +6,7 @@ import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSum
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { PaymentMethod } from '../../payment/types/PaymentMethod';
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import { CheckoutSession } from '../entities/CheckoutSession';
@@ -26,11 +26,21 @@ export class CheckoutSessionService {
async findById(id: string): Promise<CheckoutSession | null> {
return this.sessionRepo.findOne({
where: { id },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
relations: [
'lines',
'discounts',
'invoice',
'invoice.moneroDetails',
'invoice.btcDetails',
'invoice.payments'
]
});
}
async createFromCartSummary(summary: CookieCartSummary): Promise<CheckoutSession> {
async createFromCartSummary(
summary: CookieCartSummary,
requestedPaymentMethod: PaymentMethod
): Promise<CheckoutSession> {
if (summary.cartExtended.length === 0) {
throw new BadRequestException('Your cart is empty');
}
@@ -42,7 +52,7 @@ export class CheckoutSessionService {
const sessionId = randomUUID();
const invoice = await this.invoiceService.issueInvoice({
paymentMethod: PaymentMethod.Xmr,
paymentMethod: requestedPaymentMethod,
reason: InvoiceReason.Checkout,
contextId: sessionId,
amountFiat: summary.cartTotalPrice
@@ -84,7 +94,7 @@ export class CheckoutSessionService {
async cancelSession(sessionId: string): Promise<void> {
const session = await this.sessionRepo.findOne({
where: { id: sessionId },
relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments']
relations: ['invoice', 'invoice.moneroDetails', 'invoice.btcDetails', 'invoice.payments']
});
if (!session) {
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
const checkoutInvoiceView = {
cryptoCurrency: 'XMR',
paymentLabel: 'XMR',
amountFiat: 9
} as unknown as StorefrontInvoiceView;
@@ -1,8 +1,9 @@
import { IsIn, IsNotEmpty } from 'class-validator';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
export class SetThemePreferenceDto {
@IsNotEmpty()
@IsIn(['light', 'dark'])
@IsIn(STOREFRONT_THEME_PREFERENCES)
theme: StorefrontThemePreference;
}
@@ -1,4 +1,5 @@
:root {
color-scheme: light;
--sf-font-sans:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
--sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -48,10 +49,14 @@
--sf-split-sidebar: minmax(220px, 320px);
--sf-product-card-min: 11rem;
--sf-product-card-max: 22rem;
--sf-blog-card-min: 17rem;
--sf-blog-card-max: 28rem;
--sf-sticky-header-scroll-padding: 9rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
color-scheme: dark;
--sf-bg: #0a0a0a;
--sf-surface: #141414;
--sf-surface-muted: #262727;
@@ -85,6 +90,7 @@
}
:root[data-theme='dark'] {
color-scheme: dark;
--sf-bg: #0a0a0a;
--sf-surface: #141414;
--sf-surface-muted: #262727;
@@ -117,6 +123,7 @@
}
:root[data-theme='light'] {
color-scheme: light;
--sf-bg: #ffffff;
--sf-surface: #ffffff;
--sf-surface-muted: #f5f7fa;
@@ -154,8 +161,28 @@
box-sizing: border-box;
}
.sf-js-only {
display: none;
}
.js .sf-js-only {
display: block;
}
.js .sf-js-only.sf-copy-btn {
display: inline-flex;
}
.sf-nojs-only {
display: block;
}
.js .sf-nojs-only {
display: none;
}
html {
color-scheme: light dark;
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
}
body.sf-body {
@@ -288,9 +315,13 @@ a:hover {
}
.sf-header {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--sf-space-4);
padding: var(--sf-space-3) 0;
border-bottom: 1px solid var(--sf-border);
background: var(--sf-bg);
}
.sf-header__inner {
@@ -355,9 +386,51 @@ a:hover {
gap: var(--sf-space-1);
}
.sf-rates {
display: flex;
flex-wrap: wrap;
gap: var(--sf-space-3);
align-items: center;
}
.sf-rate {
display: inline-flex;
align-items: center;
gap: var(--sf-space-1);
font-size: 0.95rem;
color: var(--sf-text-muted);
white-space: nowrap;
}
@media (max-width: 768px) {
.sf-header__inner {
flex-direction: column;
align-items: center;
justify-content: center;
}
.sf-header__brand-group {
flex-direction: column;
align-items: center;
}
.sf-nav,
.sf-rates {
width: 100%;
max-width: 100%;
justify-content: center;
}
}
.sf-crypto-icon {
width: 1.25rem;
height: 1.25rem;
object-fit: contain;
flex-shrink: 0;
}
.sf-btn--pay {
gap: var(--sf-space-2);
}
.sf-category-nav {
@@ -488,6 +561,70 @@ a:hover {
border-color: var(--sf-warning-border);
}
.sf-alert--toast {
position: fixed;
top: var(--sf-space-5);
left: 50%;
z-index: 20;
display: inline-flex;
align-items: center;
gap: var(--sf-space-2);
width: max-content;
max-width: min(24rem, calc(100% - 2 * var(--sf-space-4)));
margin: 0;
padding: 0.6875rem 0.9375rem;
font-size: 0.875rem;
line-height: 1.25;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
pointer-events: none;
transform: translateX(-50%);
}
.sf-alert--toast.sf-alert--success {
animation: sf-toast-dismiss 3s ease forwards;
}
.sf-alert--toast.sf-alert--error {
animation: sf-toast-dismiss 6s ease forwards;
}
.sf-alert--toast::before {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: var(--sf-radius-full);
color: var(--sf-on-accent);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
}
.sf-alert--toast.sf-alert--success::before {
content: '✓';
background: var(--sf-success);
}
.sf-alert--toast.sf-alert--error::before {
content: '×';
background: var(--sf-error);
}
@keyframes sf-toast-dismiss {
0%,
70% {
opacity: 1;
visibility: visible;
}
100% {
opacity: 0;
visibility: hidden;
}
}
.sf-btn {
display: inline-flex;
align-items: center;
@@ -508,6 +645,10 @@ a:hover {
border-color 0.1s;
}
.sf-btn.sf-js-only {
display: none;
}
.sf-btn:hover {
color: var(--sf-accent);
background: var(--sf-accent-subtle);
@@ -767,7 +908,7 @@ a:hover {
margin: 0;
}
.sf-line-item__body > .sf-discount-error {
.sf-line-item__body > .sf-cart-issue {
margin: 0.125rem 0 0;
}
@@ -844,8 +985,8 @@ a:hover {
color: var(--sf-text-muted);
}
.sf-discount-error {
margin: 0 0 var(--sf-space-2);
.sf-cart-issue {
margin: var(--sf-space-2) 0;
font-size: 0.85rem;
color: var(--sf-error);
line-height: 1.35;
@@ -1142,7 +1283,7 @@ a:hover {
.sf-qr-wrap {
text-align: center;
margin-bottom: var(--sf-space-4);
margin: var(--sf-space-4) 0;
}
.sf-tx-list {
@@ -1181,6 +1322,64 @@ a:hover {
font-size: 0.9rem;
}
.sf-copy-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sf-space-2);
}
.sf-copy-row .sf-payment-amount {
margin: 0;
}
.sf-copy-row--inline {
display: inline-flex;
vertical-align: baseline;
}
.sf-copy-row--address {
flex-wrap: nowrap;
align-items: center;
margin-bottom: var(--sf-space-4);
}
.sf-copy-row__value {
flex: 1 1 auto;
min-width: 0;
margin: 0;
word-break: break-all;
}
.sf-copy-btn {
flex-shrink: 0;
min-width: 1.375rem;
min-height: 1.375rem;
padding: 0.125rem;
}
.sf-copy-btn__icon {
display: block;
line-height: 0;
}
.sf-copy-btn__icon svg {
width: 0.875rem;
height: 0.875rem;
}
.sf-copy-btn__icon--success {
display: none;
}
.sf-copy-btn--copied .sf-copy-btn__icon--copy {
display: none;
}
.sf-copy-btn--copied .sf-copy-btn__icon--success {
display: block;
}
.sf-order-status {
font-weight: 600;
text-transform: capitalize;
@@ -1401,3 +1600,75 @@ a:hover {
font-size: 2rem;
margin: 0 0 var(--sf-space-2);
}
.sf-blog-grid {
display: grid;
gap: var(--sf-space-3);
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 36rem) {
.sf-blog-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-blog-card-min)), 1fr));
}
}
.sf-blog-card {
width: 100%;
max-width: var(--sf-blog-card-max);
min-width: 0;
justify-self: start;
}
.sf-blog-card__link {
display: flex;
flex-direction: column;
gap: var(--sf-space-2);
color: inherit;
text-decoration: none;
}
.sf-blog-card__link:hover .sf-blog-card__title {
color: var(--sf-link-hover);
}
.sf-blog-card__media {
aspect-ratio: 16 / 9;
background: var(--sf-surface-muted);
border-radius: var(--sf-radius-sm);
overflow: hidden;
}
.sf-blog-card__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.sf-blog-card__title {
font-size: 0.9375rem;
line-height: 1.4;
font-weight: 600;
}
.sf-blog-detail__banner {
margin-bottom: var(--sf-space-4);
background: var(--sf-surface-muted);
border-radius: var(--sf-radius-sm);
overflow: hidden;
}
.sf-blog-detail__banner-image {
display: block;
width: 100%;
max-height: 24rem;
object-fit: cover;
}
.sf-blog-detail__title {
margin-bottom: var(--sf-space-4);
}
.sf-blog-detail__content {
max-width: 42rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,52 @@
(() => {
const copiedAriaLabel = 'Copied';
const resetMs = 2000;
const copyText = async text => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
};
const handleCopyClick = async event => {
const button = event.currentTarget;
const text = button.dataset.copyText;
if (!text) {
return;
}
const defaultAriaLabel = button.dataset.copyAriaLabel ?? 'Copy';
try {
await copyText(text);
button.classList.add('sf-copy-btn--copied');
button.setAttribute('aria-label', copiedAriaLabel);
window.setTimeout(() => {
button.classList.remove('sf-copy-btn--copied');
button.setAttribute('aria-label', defaultAriaLabel);
}, resetMs);
} catch (error) {
console.error('Copy to clipboard failed', error);
}
};
for (const button of document.querySelectorAll('[data-action="copy"]')) {
button.addEventListener('click', handleCopyClick);
}
})();
@@ -0,0 +1 @@
document.documentElement.classList.replace('no-js', 'js');
@@ -7,51 +7,45 @@ import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => {
type ServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
theme?: 'light' | 'dark';
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string };
fiatPerXmr?: number;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
const defaultPage: StorefrontPageMetaInput = {
title: 'Cart',
metaDescription: 'Review your cart.'
};
const createService = (overrides: ServiceOverrides = {}) => {
const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
const {
cart = [],
feedback,
authorizedOrderIds = [],
theme,
checkoutSessionId,
theme = 'system',
branding = {
logoUrl: null,
faviconUrl: null,
simplexLink: null,
shippingNote: null
shippingNote: null,
blogEnabled: false
},
shopSettings = {
shopName: 'Demo Shop',
shopFiatCurrency: 'USD',
enabledPaymentMethods: [PaymentMethod.Xmr, PaymentMethod.Btc]
},
shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' },
fiatPerXmr = 150,
fiatPerBtc = 60_000,
req: reqOverrides = {}
} = overrides;
const exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
getLiveFiatPerCrypto: jest.fn((method: PaymentMethod) =>
method === PaymentMethod.Btc ? fiatPerBtc : fiatPerXmr
)
} as unknown as ExchangeRateService;
const configService = {
get: jest.fn().mockReturnValue(shopSettings)
@@ -71,6 +65,9 @@ describe('StorefrontShopViewService', () => {
const themeCookieService = {
getTheme: jest.fn().mockReturnValue(theme)
} as unknown as StorefrontThemeCookieService;
const checkoutSessionCookieService = {
getSessionId: jest.fn().mockReturnValue(checkoutSessionId)
} as unknown as StorefrontCheckoutSessionCookieService;
const service = new StorefrontShopViewService(
exchangeRateService,
@@ -79,7 +76,8 @@ describe('StorefrontShopViewService', () => {
cartCookieService,
feedbackCookieService,
orderAuthCookieService,
themeCookieService
themeCookieService,
checkoutSessionCookieService
);
const req = {
@@ -108,6 +106,23 @@ describe('StorefrontShopViewService', () => {
ogImageUrl: null,
productJsonLd: null
});
expect(locals.themePreference).toBe('system');
expect(locals.hasActiveCheckout).toBe(false);
});
it('exposes active checkout state from the checkout session cookie', async () => {
const { service, req, res } = createService({
checkoutSessionId: 'session-1',
req: { path: '/shop/checkout', originalUrl: '/shop/checkout' }
});
const locals = await service.buildShopRenderLocals(req, res, {
title: 'Checkout',
metaDescription: 'Complete your purchase at {shopName}.'
});
expect(locals.hasActiveCheckout).toBe(true);
expect(locals.shopNavActive).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
});
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
@@ -122,9 +137,11 @@ describe('StorefrontShopViewService', () => {
logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico',
simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days'
shippingNote: 'Ships in 3 days',
blogEnabled: true
},
fiatPerXmr: 200
fiatPerXmr: 200,
fiatPerBtc: 80_000
});
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
@@ -134,11 +151,15 @@ describe('StorefrontShopViewService', () => {
cartTotalQty: 3,
feedback: { type: 'success', text: 'Added to cart' },
shopFiatCurrency: 'USD',
fiatPerXmr: 200,
cryptoRates: [
{ paymentLabel: 'XMR', fiatPerCrypto: 200, iconUrl: '/shop/assets/img/xmr.png' },
{ paymentLabel: 'BTC', fiatPerCrypto: 80_000, iconUrl: '/shop/assets/img/btc.png' }
],
logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico',
simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days',
blogEnabled: true,
themePreference: 'dark'
});
});
@@ -264,7 +285,7 @@ describe('StorefrontShopViewService', () => {
it('uses shop fiat currency in product json-ld', async () => {
const { service, req, res } = createService({
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR' },
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR', enabledPaymentMethods: [PaymentMethod.Xmr] },
req: {
path: '/shop/products/1/variants/2',
originalUrl: '/shop/products/1/variants/2'
@@ -8,14 +8,18 @@ import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
import type { StorefrontCryptoRate } from '../types/StorefrontCryptoRate';
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
import { resolveShopNavActive } from '../utils/resolveShopNavActive';
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@@ -29,7 +33,8 @@ export class StorefrontShopViewService {
private readonly cartCookieService: StorefrontCartCookieService,
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService,
private readonly themeCookieService: StorefrontThemeCookieService
private readonly themeCookieService: StorefrontThemeCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService
) {}
async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise<ShopRenderLocals> {
@@ -38,12 +43,15 @@ export class StorefrontShopViewService {
const themePreference = this.themeCookieService.getTheme(req, res);
const cartTotalQty = getTotalCartQtyFromCart(cart);
const hasActiveCheckout = Boolean(this.checkoutSessionCookieService.getSessionId(req, res));
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
'shopSettings'
) as Config['shopSettings'];
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } =
await this.shopSettingsService.getStorefrontBranding();
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
@@ -57,15 +65,17 @@ export class StorefrontShopViewService {
title: page.title,
shopName,
cartTotalQty,
hasActiveCheckout,
feedback,
authorizedOrders,
shopNavActive,
shopFiatCurrency,
fiatPerXmr,
cryptoRates,
logoUrl,
faviconUrl,
simplexLink,
shippingNote,
blogEnabled,
themePreference,
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
};
@@ -130,4 +140,22 @@ export class StorefrontShopViewService {
return JSON.stringify(data);
}
private buildCryptoRates(enabledPaymentMethods: PaymentMethod[]): StorefrontCryptoRate[] {
return enabledPaymentMethods.flatMap(paymentMethod => {
const fiatPerCrypto = this.exchangeRateService.getLiveFiatPerCrypto(paymentMethod);
if (fiatPerCrypto === null) {
return [];
}
return [
{
paymentLabel: paymentMethodLabel[paymentMethod],
fiatPerCrypto,
iconUrl: paymentMethodIconUrl[paymentMethod]
}
];
});
}
}

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