Compare commits

...

136 commits

Author SHA1 Message Date
Roland
d8ef0e70e0
chore: sync Wails CLI version in CI with go.mod (v2.14.0) (#2547)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
The Dependabot bump updated github.com/wailsapp/wails/v2 to v2.14.0 in
go.mod but the wails workflow still installed the CLI at v2.12.0. Align
the CI install and document in AGENTS.md that both must be updated
together.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 14:41:11 +07:00
Roland
d2cebc5c6f
fix: replace react-lottie with lottie-react for Vite 8 compatibility (#2546)
Vite 8 changed CJS default-import interop: with "type": "module" set,
a default import of a CJS dependency now resolves to the whole
module.exports object instead of its .default export. react-lottie is
CJS-only, so <Lottie> received an object as the element type and
crashed LottieLoading/LottieSuccess with "Element type is invalid".

Swap to the maintained, ESM-built lottie-react, aliasing it to its ES
build since its browser field points at a UMD build with the same
interop hazard. No other dependency is affected by the interop change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 23:52:50 +07:00
Roland
edd283cdb2
fix: update encryption scheme for node migration files (#2539)
* fix: update encryption scheme for node migration files

Migration files are now encrypted with AES-CTR using a key derived via
Argon2 with a 32-byte salt, the same derivation used for encrypted
configuration values. Files created by earlier versions can still be
restored: the restore path detects the scheme by trial-decrypting the
archive header and checking for the ZIP file signature, which also
rejects an incorrect unlock password up front instead of extracting
garbage.

The migration screen now also tells users to never share their
migration file with anyone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reword migration file warning

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: read full migration file header before detecting cipher scheme

io.ReadAtLeast can return once the smallest scheme's header is read,
which truncates the larger current-scheme header when the reader
delivers short reads (e.g. a network request body). Read the full
header and only tolerate a short read that still covers the smallest
scheme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: extract migration files to a staging directory during restore

If extraction failed partway through, the partially populated restore
directory was left in the working directory, and the next startup would
apply the incomplete restore. Extract to a staging directory and only
move it into place after every entry has been extracted successfully.
Also reject archives that contain no files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: assert traversal-specific error in restore backup test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 22:14:49 +07:00
Roland
1c7abc62e9
chore: add Bark terms link and exit disclosure to security page (#2544)
* chore: add Bark terms link and exit disclosure to security page

Link Second's Terms of Service from the Bark setup security screen, note
that the hub must stay online so automatically refreshed funds do not
expire, and clarify (via tooltip) that unilateral exit is not built into
Alby Hub yet and must be executed manually with the wallet data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: improve copy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 22:04:43 +07:00
Roland
1c7c026e54
chore: make vite config compatible with native config loader (#2542)
Replace __dirname with import.meta.dirname and use Vite's native
resolve.tsconfigPaths option instead of the vite-tsconfig-paths plugin.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 21:40:43 +07:00
Roland
5125418188
chore(deps): bump dependencies to fix Dependabot alerts (#2541)
* chore(deps): bump google.golang.org/grpc to v1.82.1 and edwards25519 to v1.1.1

Fixes Dependabot alerts GHSA-hrxh-6v49-42gf (gRPC-Go xDS RBAC and HTTP/2
vulnerabilities) and GHSA-fw7p-63qq-7hpr (edwards25519 MultiScalarMult).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump react-router to 7.18.2 and refresh vulnerable transitive deps

Bumps react-router 7.14.2 -> 7.18.2 and re-resolves fast-uri, js-yaml,
brace-expansion, minimatch, picomatch, flatted and @babel packages to
patched versions, clearing the remaining open npm Dependabot alerts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:57:27 +07:00
Roland
363c22f6d3
fix: switch unlock rate limiter from per-IP to global (#2540)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
The unlock endpoints were rate limited per client IP, which is derived
from request headers and so is chosen by the caller. Switch to a single
global rate limiter (one bucket for all callers) and apply it to every
endpoint that verifies the unlock password: start, unlock, backup,
mnemonic, apps, autoswap, unlock-password and auto-unlock. A small burst
keeps unlocking and immediately performing an action working.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 16:25:56 +07:00
Roland
3b3c37dd0c
fix: remove legacy acceptance of empty unlock password check (#2534)
* fix: remove legacy acceptance of empty unlock password check

CheckUnlockPassword previously treated a missing or empty
UnlockPasswordCheck value as a match — a legacy compatibility path from
before the canary was always written. It now requires the stored value
to be present and to equal the expected string.

StartApp checks for the canary up front and, if it is missing, stops
with a message asking the user to restore from a backup rather than
continuing. A new IsUnlockPasswordCheckSet helper reports whether the
value is present.

keys.Init now returns the error from reading NostrSecretKey instead of
ignoring it, so a read failure aborts instead of generating and saving a
new key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: add operation context to unlock password check errors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 14:48:06 +07:00
Roland
979644cf68
fix: limit LSP opening fees for JIT channel invoices (#2535)
* fix: limit LSP opening fees for JIT channel invoices

JIT channel invoices are now created with a maximum LSP opening fee
instead of no limit: the fee the LSP advertises in its LSPS2 opening fee
menu for the payment size, bounded by an absolute ceiling of 5000 sats
or 10% of the payment, whichever is greater. Invoice creation fails if
the LSP quotes a fee above this limit.

The minimum JIT payment size calculation now uses the same ceiling so
the advertised receivable range matches what invoice creation accepts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: validate invoice expiry range and guard LSPS2 cache reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 13:38:13 +07:00
Roland
f4010e239a
fix: validate swap out invoice before payment (#2536)
Verify the invoice returned when creating a swap out before storing and
paying it:

- the invoice payment hash must match the payment hash of the locally
  generated preimage
- the invoice amount must not exceed the requested amount plus the
  quoted service and miner fees (with a small rounding tolerance)
- the lockup address is checked against the swap tree, matching the
  checks already performed for swap in and refunds
- the invoice is verified again directly before it is paid

Also renames AlbySwapServiceFee to AlbySwapServiceFeePercentage for
clarity.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:33:40 +07:00
Roland
4c5bef42c6
fix: require full access api key for log endpoint (#2537)
Move GET /api/log/:type from the read-only API group to the
full-access group, matching /api/swaps/mnemonic. Add tests asserting
a readonly token receives 403 from the log endpoint and a full-access
token can still read it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:29:03 +07:00
Adithya Vardhan
0c24ab84c1
fix: avoid leaking raw postgres error details in duplicate key response (#2538) 2026-08-11 18:45:59 +05:30
dependabot[bot]
459b825cfb
build(deps): bump github.com/lightningnetwork/lnd from 0.21.0-beta to 0.21.1-beta (#2517)
build(deps): bump github.com/lightningnetwork/lnd

Bumps [github.com/lightningnetwork/lnd](https://github.com/lightningnetwork/lnd) from 0.21.0-beta to 0.21.1-beta.
- [Release notes](https://github.com/lightningnetwork/lnd/releases)
- [Changelog](https://github.com/lightningnetwork/lnd/blob/master/docs/release_branch_management.md)
- [Commits](https://github.com/lightningnetwork/lnd/compare/v0.21.0-beta...v0.21.1-beta)

---
updated-dependencies:
- dependency-name: github.com/lightningnetwork/lnd
  dependency-version: 0.21.1-beta
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:07:03 +05:30
dependabot[bot]
e3278beffb
build(deps): bump github.com/mattn/go-sqlite3 from 1.14.48 to 1.14.49 (#2514)
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.48 to 1.14.49.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.48...v1.14.49)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.49
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 16:36:30 +05:30
dependabot[bot]
852b30fa02
build(deps): bump gorm.io/driver/postgres from 1.6.0 to 1.6.2 (#2515)
Bumps [gorm.io/driver/postgres](https://github.com/go-gorm/postgres) from 1.6.0 to 1.6.2.
- [Commits](https://github.com/go-gorm/postgres/compare/v1.6.0...v1.6.2)

---
updated-dependencies:
- dependency-name: gorm.io/driver/postgres
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 16:36:15 +05:30
dependabot[bot]
ffc705e536
build(deps): bump github.com/wailsapp/wails/v2 from 2.12.0 to 2.14.0 (#2513)
* build(deps): bump github.com/wailsapp/wails/v2 from 2.12.0 to 2.13.0

Bumps [github.com/wailsapp/wails/v2](https://github.com/wailsapp/wails) from 2.12.0 to 2.13.0.
- [Release notes](https://github.com/wailsapp/wails/releases)
- [Commits](https://github.com/wailsapp/wails/compare/v2.12.0...v2.13.0)

---
updated-dependencies:
- dependency-name: github.com/wailsapp/wails/v2
  dependency-version: 2.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: bump wails version to v2.14.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adithya Vardhan <imadithyavardhan@gmail.com>
2026-08-11 16:22:33 +05:30
dependabot[bot]
d3847fdaae
build(deps-dev): bump vite from 5.4.19 to 8.2.0 in /frontend (#2516)
* build(deps-dev): bump vite from 5.4.19 to 8.2.0 in /frontend

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 8.2.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: remove stale react paths override in tsconfig

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adithya Vardhan <imadithyavardhan@gmail.com>
2026-08-11 16:20:48 +05:30
dependabot[bot]
7c7dfa6876
build(deps): bump lucide-react from 1.7.0 to 1.28.0 in /frontend (#2518)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.7.0 to 1.28.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.28.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 15:48:31 +05:30
dependabot[bot]
621db07fc8
build(deps): bump @fontsource-variable/figtree from 5.2.10 to 5.3.0 in /frontend (#2519)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
build(deps): bump @fontsource-variable/figtree in /frontend

Bumps [@fontsource-variable/figtree](https://github.com/fontsource/font-files/tree/HEAD/fonts/variable/figtree) from 5.2.10 to 5.3.0.
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/variable/figtree)

---
updated-dependencies:
- dependency-name: "@fontsource-variable/figtree"
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 15:40:39 +05:30
Roland
4402d2fff8
fix: remove request bodies from error logs (#2533)
The Wails request router included the full request body in its error
log entries, and the HTTP app creation handler logged the whole request
struct on failure. Log only the route, method and error instead,
matching the existing behavior of the /api/mnemonic handler, and log
only the route and method for requests in the desktop frontend.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:49:26 +07:00
Roland
5f9a88843c
fix: validate LND and CLN credential files during setup (#2528)
The setup API accepts file paths for the LND certificate and macaroon
and for the CLN lightning directory. Previously the raw file contents
were read and stored without any validation.

Validate these inputs before persisting them:

- LND cert: parse the PEM and store only the re-encoded certificate(s),
  discarding any other PEM blocks (e.g. a bundled private key).
- LND macaroon: unmarshal and store the re-marshalled macaroon.
- CLN lightning directory: verify it contains the TLS credentials
  (ca.pem, client.pem, client-key.pem) that CLN loads at connect time,
  including the hold subdirectory when configured.

On failure, return a generic error to the client and log the detail
server-side. File paths remain supported for Umbrel-style installs.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:05:56 +07:00
Roland
3d22993389
fix: validate return_to redirect URLs (#2532)
return_to query parameters are now parsed and only http and https URLs
are used for redirects, both in the frontend and when the createApp API
adds the connection parameters to the URL.

The production frontend build now also includes the same
Content-Security-Policy meta tag that is served as a header in http
mode, so the policy also applies where no HTTP headers are set, e.g. in
the desktop app.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:57:51 +07:00
Roland
0b0cbbd985
fix: make event assertions in tests wait for async event consumption (#2531)
The mock event consumer waited a fixed 10ms before returning consumed
events, which was not always enough on slow CI runners and caused flaky
failures (e.g. TestMarkSettled_App_BudgetWarning missing its
nwc_budget_warning event). It also appended to the events slice from
concurrent goroutines without synchronization, a data race that could
drop events.

- guard the consumed events slice with a mutex and return copies
- add WaitForConsumedEvents which polls until the expected number of
  events arrived (up to 5s) instead of relying on a fixed sleep
- use it in tests that assert on consumed events; tests asserting that
  no event was published keep the short grace period
- normalize event order in the keysend self-payment test, matching the
  existing approach in the self-payment test, since async publishing
  does not guarantee ordering

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:48:41 +07:00
Roland
fa5cc3511e
fix: prevent backup restore from writing outside the restore directory (#2529)
Archive entry names come from the uploaded backup and were joined to the
restore directory without validation, so an entry name containing ".."
segments could resolve to a path outside it. Reject entries whose name is
absolute or escapes the restore directory, and confirm the cleaned
destination path stays within it before writing.

Add a test covering rejection of an entry that points outside the restore
directory.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 23:42:45 +07:00
Roland
037765794d
fix: keep showing migration success page after creating migration file (#2527)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
* fix: keep showing migration success page after creating migration file

After creating a node migration file the hub is halted and the Alby
OAuth token is intentionally removed, so visiting the homepage sent the
user through /start into the Alby OAuth flow. Track the halted state
in memory and redirect back to the migration success page instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: synchronize migration flag access and propagate zip close error

Make nodeMigrationFileCreated an atomic.Bool since it is written by
CreateBackup and read by GetInfo on concurrent HTTP handler goroutines,
and finalize the migration archive explicitly so a failed zip close
returns an error instead of reporting a corrupt backup as success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: return minimal info response after migration file is created

Once a migration file is created the hub is halted and the database is
closed. GetInfo previously only worked because every config key it reads
happened to be served from the config cache; any cache miss on an
error-propagating read would fail /api/info. Return early with a minimal
response instead so the migration success page does not depend on cache
state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:37:26 +07:00
Roland
56d118a851
fix: remove dollar sign from linux install scripts (#2525) 2026-08-10 15:33:20 +07:00
Roland
ffee8cbcbe
feat: enable migration from postgres to sqlite (#2524)
Allows users running Alby Hub on postgres (e.g. Alby Cloud) to create a
migration file from Settings -> Migrate Alby Hub. The contents of the
postgres database are copied into a temporary local sqlite database
which is included in the migration file, so it can be imported into a
fresh sqlite-based hub.

- extract the db_migrate CLI copy logic into a shared db.MigrateDB
- also copy the swaps and forwards tables (previously silently dropped)
- only require VSS in the source when migrating to postgres
- show a hint on the migrate page when running on postgres
- show database storage type and VSS status on the about page
- don't log an error when removing non-existent db files before restore

Closes #2500

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:20:41 +07:00
Roland
6d0cb6fd2c
fix: bark onboarding, migration messaging and receive settlement for bark 0.6.0 (#2523)
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
* fix: update bark onboarding and backup messaging for seed-based recovery

Since bark 0.6.0, offchain funds are recoverable from the mnemonic
alone via the seed-derived recovery mailbox. Remove the outdated
warnings that the recovery phrase is not sufficient, show the standard
recovery guidance for bark during onboarding, and expose the
seed-recovery scan result as a 'recoveryreport' custom node command so
users migrating to a new device can verify their funds were restored.

Closes #2512

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: settle bark lightning receives in the new delivering state

bark 0.6.0 added a 'delivering' receive state between preimage reveal
and settlement. The receive claim handler only treated
'preimage-revealed' and 'settled' as paid, so claimed receives were
published without a preimage and the transactions service rejected the
settlement ('no preimage in payment'), leaving paid invoices pending
forever.

Recognize all states at or past preimage reveal via a receiveIsPaid
helper (a positive allowlist, so an unknown future state degrades to
pending rather than falsely settled), only mark the transaction settled
when the preimage is present, and prefer bark's own settled_at
timestamp when available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: replace import channels checkbox with LDK-specific warning

The 'I don't have another Alby Hub to migrate or open channels'
checkbox on the import recovery phrase screen only applied to LDK but
was required for every backend, and its claim that channel funds are
always lost is wrong when dynamic channel backups (VSS) are enabled.

Remove the checkbox and the channels bullet from the import screen and
show the caveat on the Security & Recovery page instead, only when a
mnemonic was imported and the LDK backend was chosen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:39:02 +07:00
Roland
d198b19bef
feat: enable typing card name when choosing other card (#2511)
* feat: enable typing card name when choosing other card

Closes #2457

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reset connect-card dialog form on open and show empty name validation error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: use shadcn Button for other-card option in connect dialog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:38:46 +07:00
Roland
5f4e52bd88
fix: publish transaction events only after the database transaction commits (#2520)
* fix: publish transaction events only after the database transaction commits

markTransactionSettled and markPaymentFailed published nwc_payment_sent /
nwc_payment_received / nwc_payment_failed (and checkBudgetUsage published
nwc_budget_warning) while still inside the caller's database transaction, so
connected apps and the Alby API could be notified of a payment whose row was
never committed, and subscribers reading the database in response to an event
could race with the commit.

Every function that writes transaction state now owns its own database
transaction and publishes its events only after the commit succeeds:

- markTransactionSettled and markPaymentFailed open their own transaction;
  callers no longer wrap them in db.Transaction
- new createSettledTransactionFromNotification inserts transactions reported
  by LNClient notifications for payments the hub has no record of (external
  payments, received keysends) directly in their settled state, removing the
  transient PENDING row and the zombie row left behind on duplicate events
- markPaymentFailed now refuses to mark a settled transaction as failed,
  replacing CancelHoldInvoice's in-transaction ACCEPTED re-check and also
  protecting the SendPaymentSync error path from a racing settle
- checkBudgetUsage returns the budget warning event instead of publishing it

Closes #2506

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: serialize payment failure with settlement and propagate lock errors

Address review findings on the previous commit:

- markPaymentFailed now takes the same payment-hash row lock as settlement
  (postgres), so the settled-state guard cannot be bypassed by a concurrent
  settle between the state check and the update; it also returns not-found
  instead of publishing an event when the transaction row no longer exists,
  and reports whether this call transitioned the row so CancelHoldInvoice
  only publishes nwc_hold_invoice_canceled when it performed the cancellation
- findSettledTransaction propagates errors from the lock query and the
  settled-transaction lookup instead of treating a failed lookup as
  "no settled transaction exists", which could defeat the dedup guard
- TestMarkSettled_Twice no longer shares one transaction struct between
  concurrent goroutines and collects errors instead of asserting inside them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: mark failed keysend payments via markPaymentFailed

The SendKeysend failure path updated the transaction directly, which never
zeroed the fee reserve, recorded no failure reason, published no
nwc_payment_failed event, and had no guard against overwriting a
concurrently settled payment. Route it through markPaymentFailed like
SendPaymentSync, and allow MockLn keysends to fail so the path is testable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:38:28 +07:00
Roland
6175489cb0
chore: remove unused argon2-wasm-esm dependency (#2508)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:55:38 +07:00
Roland
bdce8fe8d2
fix: use scope constant in get_budget permission query (#2510)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
* fix: use scope constant in get_budget permission query

The get_budget controller filtered the app_permissions scope column with
models.PAY_INVOICE_METHOD, which only matched because the method and
scope constants share the same string value. Use
constants.PAY_INVOICE_SCOPE like every other scope lookup, and document
why the unchecked First result is safe.

Fixes #2503

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: return error from get_budget on unexpected permission query failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:39:07 +07:00
Roland
d94f6933f5
fix: remove avatar from lightning address QR (#2509)
The avatar overlay made the QR code hard to scan, especially for
short lightning addresses. Without center content the QR also drops
back to a lower error correction level, improving scannability.

Fixes #2507

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:20:01 +07:00
Alchemist
35d666d469
feat: filter transactions (#2464)
* feat: filter transactions

* fix: harden transaction filters

* refactor: use explicit nullable transaction filters with HideFailed polarity

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: set transaction filters in a dialog from wallet actions menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: filter transactions by search term and type

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reject invalid transaction filters and reset page synchronously

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: parse complete minimum amount value in transactions filter dialog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 10:52:14 +07:00
Josip
3b3e784fa6
fix: fallback to outgoing payments in Phoenixd LookupInvoice (#2447)
* fix: fallback to outgoing payments in Phoenixd LookupInvoice

LookupInvoice only queried /payments/incoming/{hash}, returning 404 for
outgoing payments. This caused all outgoing Lightning payments to remain
permanently stuck as PENDING in Alby Hub.

The fix tries incoming first (preserving existing behavior), then falls
back to listing outgoing payments and matching by paymentHash.

Fixes #2442

* fix: amount and fees in phoenix payment to transaction

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-08-06 17:24:10 +07:00
Roland
fbaff5d8a0
fix: show icon and proper name for lightning node backend on about page (#2501)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 17:21:00 +07:00
Peter
355454a4cc
fix: Mandatory upgrade for bark SDK (#2502)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
chore: update bark bindings to v0.15.0

Moves from bark 0.4.0 to 0.6.0. The Ark server now requires protocol version 5
(hashlock clauses) to start a lightning receive, which only bark 0.6.0 sends, so
older clients are refused outright and cannot generate invoices.

No client changes needed: v0.15.0 only adds to the surface we use.
2026-08-06 10:43:41 +07:00
Roland
806dfd4e1b
chore: bump ldk node dependencies (#2498)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
- rust-lightning to 0.2.4
2026-08-05 15:51:25 +07:00
Roland
d3455eee7d
fix: explain API access is unavailable in the desktop app (#2499)
Creating a developer token in the Wails build failed with a confusing
"Unhandled route: POST /api/unlock" error, because the desktop app does
not expose an HTTP API for the token to be used against. Hide the token
creation form in the desktop build and show an explanatory message
instead.

Fixes #2471

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:26:40 +07:00
Roland
9971aa1ac8
fix: update bark icon (#2497)
Replace the bark.jpg icon with the new light and dark SVG icons.

Fixes #2446

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:08:03 +07:00
Roland
43d37d75d1
fix: bump go-nostr to fix duplicate relay connections (#2496)
Picks up getAlby/go-nostr#6, which shares relay connections in
SimplePool when dials fail, closes relay websockets on pool close,
and closes previous subscriptions before re-subscribing on CLOSED.

The shared per-relay-URL connect backoff is now enabled by default
in the fork, so no hub-side pool option is needed
(nostr.WithPenaltyBox is deprecated).

Fixes #2481

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:50:08 +07:00
Roland
8a9ba49807
chore: bump node version in dockerfile to 22 (#2495) 2026-08-05 14:29:40 +07:00
Roland
4484046ff7
fix: use async LDK event polling to avoid polling delay (#2494)
Switch from polling node.NextEvent() every second to node.NextEventAsync(),
which parks the goroutine until an event arrives without blocking an OS
thread or an LDK thread, as LDK is migrating to async event handling.

Guard event handling with a mutex held by Shutdown() so in-flight handlers
finish before the node is stopped and destroyed, and drop events that
arrive after shutdown starts (LDK redelivers unhandled events on startup).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 23:14:30 +07:00
Roland
b051e5eb5f
fix: include payment hash when querying by payment request to ensure index is used (#2480) 2026-08-04 20:40:36 +07:00
dependabot[bot]
2bafad7a6c
build(deps): bump github.com/BoltzExchange/boltz-client/v2 from 2.12.0 to 2.12.5 (#2483)
build(deps): bump github.com/BoltzExchange/boltz-client/v2

Bumps [github.com/BoltzExchange/boltz-client/v2](https://github.com/BoltzExchange/boltz-client) from 2.12.0 to 2.12.5.
- [Release notes](https://github.com/BoltzExchange/boltz-client/releases)
- [Changelog](https://github.com/BoltzExchange/boltz-client/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BoltzExchange/boltz-client/compare/v2.12.0...v2.12.5)

---
updated-dependencies:
- dependency-name: github.com/BoltzExchange/boltz-client/v2
  dependency-version: 2.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 20:39:06 +07:00
dependabot[bot]
8f9d6f73f3
build(deps): bump github.com/labstack/echo/v4 from 4.15.2 to 4.15.4 (#2460)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.15.2 to 4.15.4.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/v4.15.4/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.15.2...v4.15.4)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:21:05 +07:00
dependabot[bot]
ec2ec911be
build(deps-dev): bump @commitlint/config-conventional from 20.5.0 to 21.2.0 in /frontend (#2487)
build(deps-dev): bump @commitlint/config-conventional in /frontend

Bumps [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) from 20.5.0 to 21.2.0.
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.0/@commitlint/config-conventional)

---
updated-dependencies:
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:19:59 +07:00
Alchemist
816e0dda6e
feat: receive invoices to apps (#2466)
* feat: receive invoices to apps

* fix: handle cleared receive selector
2026-08-04 17:18:22 +07:00
saunter
417cb16d97
feat: refresh payment QR and status components (#2459)
* feat: refresh payment QR and status components

* fix: align payment success button spacing

* fix: invert payment QR colors in dark mode

* fix: address payment QR review feedback

* fix: flatten nested cards in payment review FixedFloat tiles

* chore: remove internal payment component review screen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep QR codes dark-on-light in dark mode

Inverted QR codes are unreadable by many scanner apps (e.g. Phoenix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: René Aaron <rene@getalby.com>
Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:08:45 +07:00
dependabot[bot]
5b49783478
build(deps): bump golang.org/x/crypto from 0.52.0 to 0.54.0 (#2484)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.52.0 to 0.54.0.
- [Commits](https://github.com/golang/crypto/compare/v0.52.0...v0.54.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:33:30 +07:00
dependabot[bot]
98288463de
build(deps): bump gorm.io/gorm from 1.31.1 to 1.31.2 (#2482)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.31.1 to 1.31.2.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.31.1...v1.31.2)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:32:52 +07:00
dependabot[bot]
857f145784
build(deps): bump @fontsource-variable/inter from 5.2.8 to 5.3.0 in /frontend (#2485)
build(deps): bump @fontsource-variable/inter in /frontend

Bumps [@fontsource-variable/inter](https://github.com/fontsource/font-files/tree/HEAD/fonts/variable/inter) from 5.2.8 to 5.3.0.
- [Changelog](https://github.com/fontsource/font-files/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/variable/inter)

---
updated-dependencies:
- dependency-name: "@fontsource-variable/inter"
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:31:20 +07:00
dependabot[bot]
c99ca51ad5
build(deps): bump github.com/mattn/go-sqlite3 from 1.14.46 to 1.14.48 (#2486)
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.46 to 1.14.48.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.46...v1.14.48)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.48
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:29:20 +07:00
dependabot[bot]
fa03a9ba70
build(deps): bump @getalby/sdk from 7.0.0 to 8.0.3 in /frontend (#2488)
* build(deps): bump @getalby/sdk from 7.0.0 to 8.0.3 in /frontend

Bumps [@getalby/sdk](https://github.com/getAlby/js-sdk) from 7.0.0 to 8.0.3.
- [Release notes](https://github.com/getAlby/js-sdk/releases)
- [Commits](https://github.com/getAlby/js-sdk/compare/v7.0.0...v8.0.3)

---
updated-dependencies:
- dependency-name: "@getalby/sdk"
  dependency-version: 8.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: bump node version in workflows

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-08-04 16:25:24 +07:00
hermes-alby
e0c173ae1e
docs: add security policy (#2491)
* docs: add security policy

* docs: link security policy from README

---------

Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
2026-08-04 16:14:54 +07:00
hermes-alby
bf0ebe1f33
ci: support builds for fork pull requests (#2492)
ci: skip macOS signing for untrusted PRs

Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
2026-08-04 16:14:30 +07:00
Roland
32af89bc8c
chore: bump rebalance fees to ensure payment succeeds (#2470)
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
2026-07-29 14:31:18 +07:00
Peter
ce46a0f8a0
chore: update bark bindings to v0.12.1 (#2477)
Moves from bark 0.2.3 to 0.4.0, which changed the FFI surface:

- WalletOpen takes the network and a WalletOpenArgs, replacing WalletCreate
  and the separate RunDaemon call.
- Bolt11Invoice takes an optional anti-DoS token, unused here.
- LightningReceiveStatus is now LightningReceiveState, reporting progress
  via State rather than a PreimageRevealed bool.

Movements expose PaymentHash and sends expose a typed terminal state, so both
are read from those instead of the movement metadata JSON. A send movement that
is neither pending nor successful now resolves the SendPaymentSync waiter
instead of being ignored.
2026-07-29 14:25:25 +07:00
Roland
be17bc4e26
chore: replace alby mutinynet lsps2 with megalith mutinynet lsp (#2469)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
2026-07-27 19:54:37 +07:00
dependabot[bot]
afdb842a54
build(deps-dev): bump @tailwindcss/forms from 0.5.10 to 0.5.11 in /frontend (#2453)
build(deps-dev): bump @tailwindcss/forms in /frontend

Bumps [@tailwindcss/forms](https://github.com/tailwindlabs/tailwindcss-forms) from 0.5.10 to 0.5.11.
- [Release notes](https://github.com/tailwindlabs/tailwindcss-forms/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss-forms/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss-forms/compare/v0.5.10...v0.5.11)

---
updated-dependencies:
- dependency-name: "@tailwindcss/forms"
  dependency-version: 0.5.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:52:09 +07:00
Anshuman
754acfc9df
fix: update wave.space affiliate URL (#2475) 2026-07-27 19:51:07 +07:00
dependabot[bot]
005ee814a8
build(deps): bump github.com/lightningnetwork/lnd from 0.20.1-beta to 0.21.0-beta (#2402)
* build(deps): bump github.com/lightningnetwork/lnd

Bumps [github.com/lightningnetwork/lnd](https://github.com/lightningnetwork/lnd) from 0.20.1-beta to 0.21.0-beta.rc3.
- [Release notes](https://github.com/lightningnetwork/lnd/releases)
- [Changelog](https://github.com/lightningnetwork/lnd/blob/master/docs/release_branch_management.md)
- [Commits](https://github.com/lightningnetwork/lnd/compare/v0.20.1-beta...v0.21.0-beta.rc3)

---
updated-dependencies:
- dependency-name: github.com/lightningnetwork/lnd
  dependency-version: 0.21.0-beta.rc3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: remove unused LND wrapper interface and methods

* chore: bump LND to v0.21.0-beta

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adithya Vardhan <imadithyavardhan@gmail.com>
2026-07-27 19:48:54 +07:00
dependabot[bot]
b6bce030d0
build(deps): bump github.com/mattn/go-sqlite3 from 1.14.45 to 1.14.46 (#2461)
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.45 to 1.14.46.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.45...v1.14.46)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:20:51 +07:00
dependabot[bot]
e6fcc3e3c1
build(deps-dev): bump typescript-eslint from 8.60.1 to 8.61.0 in /frontend (#2452)
build(deps-dev): bump typescript-eslint in /frontend

Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.60.1 to 8.61.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:19:40 +07:00
dependabot[bot]
270d23d273
build(deps-dev): bump @types/node from 25.8.0 to 25.9.3 in /frontend (#2450)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.8.0 to 25.9.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:18:50 +07:00
Michael Bumann
b70f40e42a
fix: require full access api key for swaps/mnemonic (#2473)
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
the mnemonic could be considered a non read-only route because the
mnemonic could be used.
This moves this route to the full access group to require a full access
api key.
2026-07-14 21:50:37 +07:00
Alchemist
222031a97e
fix: soften channel routing warning (#2458)
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
* fix: soften channel routing warning

* Update lnclient/ldk/ldk.go

Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com>

---------

Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com>
2026-06-19 15:48:18 +07:00
René Aaron
bf9c346a98
feat: use switch and improve copy on node settings page (#2441)
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
* feat: use switch and improve copy on node settings page

* fix: allow disabling JIT channels when no liquidity source exists
2026-06-12 11:11:17 +07:00
Roland
b1c0d4eac8
fix: add fallback instructions if JIT LSP seems to be offline (#2443)
* fix: add fallback instructions if JIT LSP seems to be offline

* fix: duplicate invoice probe

* fix: properly check if lsps2 is enabled before setting jit request failed

* fix: don't render incorrect maximum receive amount if balances aren't loaded
2026-06-12 11:09:27 +07:00
Alchemist
a21c320fde
feat: simplify receive screen (#2426)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
* feat: simplify receive screen

* fix: use absolute receive routes

* chore: move other options to separate card, improve copy

* chore: also add accordion to receive invoice screen (for non-logged-in users)

* fix: JIT alert padding

* feat: explain other receive options with descriptive menu rows

* fix: move first channel jit alert outside of card

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
Co-authored-by: René Aaron <rene@twentyuno.net>
2026-06-11 19:20:35 +07:00
Roland
864c841c07
feat: add solvocard + add provider name to bitcoin card topup (#2440) 2026-06-11 15:56:52 +07:00
Roland
d7ebee5058
chore: add bhodl lsp as trusted 0 conf peer (#2439) 2026-06-11 14:48:26 +07:00
René Aaron
2f258138bc
fix: send referrer header on stories YouTube embed (#2435) 2026-06-11 13:17:27 +07:00
Anthonyushie
5249ebe7db
fix: add lightning tag to Freedomia card (#2437) 2026-06-11 13:15:39 +07:00
Roland
cc429c4553
chore: fix formatting (#2430)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
2026-06-10 14:51:37 +07:00
Adithya Vardhan
d8faf09109
fix: avoid mempool api failures during swaps by using boltz for fee and tx lookup (#2421)
* fix: avoid mempool api failures during swaps by using boltz for fee and tx lookup

* fix: ensure swap payment isn't made twice on refresh

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-06-10 14:47:54 +07:00
frnandu
b55978d7bc
feat: just in time channels with lsps2 (#2275)
* feat: just in time channels with lsps2

* fix: clarify JIT receive channel fee

* fix: fees

* fix: fees 2

* fix: don't show low inbound when LSPS2 is active

* fix: remove the receive limit below the input if LSPS2 is being used

* fix: simplify

* fix: bring back fee % for outgoing

* fix: remove unneeded changes

* fix: typo

* fix: unneeded

* fix: don't show open first channel is LSPS2

* feat: clearer JIT channel fee copy on receive screen

* fix: add LSPS2 var info

* fix: don't duplicate JIT fee hint on create invoice form

* fix: make paymentDone a standard boolean

* fix: update to golang:1.26 in Dockerfile

* feat: read LSPS2 sources from channel suggestions, set minimum receive amount, update guide link

* docs:  update LDK_LSPS2_ADDRESSES to be used as an override

* fix: only show minimum jit receive amount on validation error

* fix: add more detail to receive error when receiving low amounts with jit

* fix: do not use JIT when user has public channels

* feat: add option to disable JIT

* fix: isTrusted check, add jit property to event

* fix: do not require node restart for toggling JIT

* chore: simplify JIT alert

* chore: add guide link on node settings JIT description

* feat: fetch the lsp2info to have access to params like minimum/maximum payment size

* refactor: share single learn-more link across JIT fee hint branches

* fix: remove variable amount invoice support

* fix: use lsps2info for min payment size and remove channelPeerSuggestion usage of minimumChannelSize

* fix: only do amount validation according to lsps2Info values if jit is enabled in settings

* feat: add jit first payment fee alert on receive via lightning address

* fix: remove unnecessary conditional

* fix: ensure at least one sat is left over when opening JIT channel

* chore: remove hardcoded suggestions

* chore: rename JIT enabled config variable

* fix: ui checks when JIT is disabled

* fix: amount input validation message

* fix: formatting

---------

Co-authored-by: anon <anon@anon.com>
Co-authored-by: saunter <68239231+stackingsaunter@users.noreply.github.com>
Co-authored-by: fmar <fmar@fmar>
Co-authored-by: René Aaron <rene@twentyuno.net>
Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-06-10 14:47:07 +07:00
Roland
e5dc19ae68
feat: add readonly option for app store apps (#2415) 2026-06-10 13:17:13 +07:00
Roland
98e7d987bb
feat: pass selected provider to card topup app (#2416)
* feat: pass selected provider to card topup app

The Bitcoin Card Topup app (card.albylabs.com) now supports configuration
presets selected via a `provider` query param. Pass the provider chosen on
the Cards page through to the topup app's install link so its preset is
pre-applied, simplifying setup.

Closes #2384

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: move bitcoin card topup install guide component to a new file

* chore: remove accidentally committed worktree gitlinks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: use more general copy for card topup app install guide

* fix: remove subtree commits

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:16:02 +07:00
Roland
c6f45e75e3
chore: update bark bindings to v0.8.0 (#2428)
* chore: update bark bindings to v0.8.0

* docs: update BARK_SERVER_ACCESS_TOKEN usage
2026-06-10 13:13:23 +07:00
René Aaron
3453b69a1c
feat: refine wallet empty states (#2382)
* fix: tighten inline empty state spacing on wallet pages

* chore: refine wallet empty states

- Replace placeholder icons (drum, link) with channel-specific icons (ZapIcon for lightning, BitcoinIcon for on-chain)
- Rewrite empty-state copy with warmer, less technical phrasing
- Drop redundant CTA (Receive button already sits above)
- Add subtle bg-accent surface to anchor the transactions section

* chore(empty-state): add variant prop, drop unused button props on wallet pages

* chore(empty-state): swap muted surface from bg-accent/40 to bg-muted

* chore(transactions): allow callers to override empty-state copy and icon

App transaction lists now show app-context messaging ('No transactions yet'
+ 'Payments made through this app will appear here.' with a ReceiptIcon)
instead of the wallet-specific lightning copy.

* chore(empty-state): drop unused 'none' variant

* chore(empty-state): default showButton to false

* chore(empty-state): drop showButton prop, derive from buttonText+buttonLink

* fix(empty-state): drop nested surface in app transactions card

Add 'none' variant and use it from AppTransactionList so the empty state
no longer renders a bg-muted box inside the already-bordered Card.

* fix(app-transactions): swap ReceiptIcon for ArrowDownUpIcon

ReceiptIcon renders a dollar sign — wrong for a bitcoin app.

* chore(empty-state): default variant to 'muted', CTA placeholders opt into 'dashed'

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-06-10 13:00:43 +07:00
Roland
8fc5cb25b4
chore: prevent committing worktrees (#2427) 2026-06-10 12:39:51 +07:00
René Aaron
d1636cbcce
fix: reduce wallet balance/transaction polling interval to 10s (#2425)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
* fix: reduce wallet balance/transaction polling interval to 10s

The wallet dashboard polls /api/balances and /api/transactions every 3s
via SWR refreshInterval. For hubs left open in a browser tab, this produces
a high, continuous volume of identical requests around the clock with little
UX benefit, since SWR already revalidates on window focus.

Raise the interval for the balances and transactions-list hooks to 10s. The
single-transaction hook (used while waiting for a specific invoice to settle)
is intentionally left at 3s, where fast updates matter and polling is
short-lived.

* refactor: drop poll-interval comments, rationale moved to PR
2026-06-09 20:52:40 +07:00
Roland
2657c89aa8
feat: reframe AI agent inspiration tab around managing the hub (#2418)
* feat: reframe AI agent inspiration tab around managing the hub

Change the Node inspiration tab on the AI & Agents page to a Hub tab focused on managing Alby Hub itself (app connections, sub-wallets, budgets) rather than lightning channels. Channel/node prompts only show when the backend supports channel management.

Closes #2401

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: icon

* chore: name

* chore: remove stray .claude/worktrees gitlinks

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: René Aaron <rene@twentyuno.net>
2026-06-09 20:50:30 +07:00
Roland
10771b7c0a
chore: remove Bitrefill custom app in favor of standard NWC connection (#2420)
* chore: remove Bitrefill custom app in favor of standard NWC connection

Bitrefill now supports Nostr Wallet Connect directly, so the custom
embedded iframe app is no longer needed. Remove the internal Bitrefill
screen and route, convert the app store entry to a standard NWC
connectable app, and drop the embed.bitrefill.com frame-src CSP
exceptions from both the backend header and the dev Vite config.

Closes #2283

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update bitrefill instructions and add mobile links

* chore: remove accidentally committed .claude/worktrees gitlinks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:11:04 +07:00
Roland
4585ac6da3
chore: document branch naming conventions (#2417)
chore: document branch naming conventions in AGENTS.md

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:38:13 +07:00
Adithya Vardhan
ab22e60224
fix: align dockerfile go version with go.mod (#2424) 2026-06-09 19:23:39 +07:00
Adithya Vardhan
ee726c0df9
chore: remove claude worktrees folder (#2422) 2026-06-09 16:31:29 +07:00
Roland
91c634c85f
fix: show confirmation progress when opening public channel from LSP (#2419)
* fix: show confirmation progress when opening public channel from LSP

Public channels require 6 confirmations before they can be gossiped and
become usable (BOLT-7), but LDK accepts channels from trusted LSP peers
as 0-conf and reports ConfirmationsRequired as nil/0. As a result the
channel-opening screen rendered an indefinite blank loading spinner
instead of confirmation progress.

Override ConfirmationsRequired to 6 for announced channels so the UI
shows the "X/6 confirmations" progress card while the channel opens.

Fixes #2294

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: remove unnecessary link in comment

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:31:48 +07:00
dependabot[bot]
7b963e944f
build(deps): bump @base-ui/react from 1.4.1 to 1.5.0 in /frontend (#2405)
Bumps [@base-ui/react](https://github.com/mui/base-ui/tree/HEAD/packages/react) from 1.4.1 to 1.5.0.
- [Release notes](https://github.com/mui/base-ui/releases)
- [Changelog](https://github.com/mui/base-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/base-ui/commits/v1.5.0/packages/react)

---
updated-dependencies:
- dependency-name: "@base-ui/react"
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 15:03:29 +07:00
dependabot[bot]
2215b04da6
build(deps-dev): bump eslint from 10.3.0 to 10.4.1 in /frontend (#2409)
Bumps [eslint](https://github.com/eslint/eslint) from 10.3.0 to 10.4.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.3.0...v10.4.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 15:01:12 +07:00
dependabot[bot]
2d4aab5bee
build(deps): bump tailwind-merge from 3.4.1 to 3.6.0 in /frontend (#2408)
Bumps [tailwind-merge](https://github.com/dcastil/tailwind-merge) from 3.4.1 to 3.6.0.
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v3.4.1...v3.6.0)

---
updated-dependencies:
- dependency-name: tailwind-merge
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 14:29:27 +07:00
dependabot[bot]
6cacd12dcc
build(deps-dev): bump vite-plugin-pwa from 1.2.0 to 1.3.0 in /frontend (#2407)
Bumps [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/vite-pwa/vite-plugin-pwa/releases)
- [Commits](https://github.com/vite-pwa/vite-plugin-pwa/compare/v1.2.0...v1.3.0)

---
updated-dependencies:
- dependency-name: vite-plugin-pwa
  dependency-version: 1.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 14:28:18 +07:00
dependabot[bot]
8c06706471
build(deps): bump github.com/btcsuite/btcd/chaincfg/chainhash from 1.1.0 to 1.2.0 (#2404)
build(deps): bump github.com/btcsuite/btcd/chaincfg/chainhash

Bumps [github.com/btcsuite/btcd/chaincfg/chainhash](https://github.com/btcsuite/btcd) from 1.1.0 to 1.2.0.
- [Release notes](https://github.com/btcsuite/btcd/releases)
- [Changelog](https://github.com/btcsuite/btcd/blob/master/CHANGES)
- [Commits](https://github.com/btcsuite/btcd/compare/btcutil/v1.1.0...btcutil/v1.2.0)

---
updated-dependencies:
- dependency-name: github.com/btcsuite/btcd/chaincfg/chainhash
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 14:10:33 +07:00
dependabot[bot]
ef1b4a088b
build(deps): bump github.com/mattn/go-sqlite3 from 1.14.44 to 1.14.45 (#2406)
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.44 to 1.14.45.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.44...v1.14.45)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 14:07:10 +07:00
dependabot[bot]
586c02251b
build(deps): bump github.com/go-gormigrate/gormigrate/v2 from 2.1.5 to 2.1.6 (#2403)
build(deps): bump github.com/go-gormigrate/gormigrate/v2

Bumps [github.com/go-gormigrate/gormigrate/v2](https://github.com/go-gormigrate/gormigrate) from 2.1.5 to 2.1.6.
- [Release notes](https://github.com/go-gormigrate/gormigrate/releases)
- [Changelog](https://github.com/go-gormigrate/gormigrate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-gormigrate/gormigrate/compare/v2.1.5...v2.1.6)

---
updated-dependencies:
- dependency-name: github.com/go-gormigrate/gormigrate/v2
  dependency-version: 2.1.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 14:02:34 +07:00
Roland
64afc2227f
fix: add validation on MakeInvoice for zero and non-whole satoshi amounts (#2413) 2026-06-09 13:25:36 +07:00
daywalker90
6a15ebadad
feat: generate CLN invoice preimage ourselves to support sub-wallets (#2412)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
2026-06-08 21:20:51 +07:00
Alchemist
d2acf9ccb1
fix: ignore 404 when deleting a lightning address (#2323) (#2410)
fix: ignore 404 when deleting an already-deleted lightning address (#2323)

When a sub-wallet's lightning address was already removed on getalby.com, the DELETE call returns 404, which surfaced as a "Failed to delete lightning address" error toast. Treat 404 as success so deletion is idempotent.
2026-06-08 14:17:20 +07:00
Roland Bewick
5c23375c08 feat: add bark logger
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
2026-06-05 16:40:43 +07:00
hermes-alby
a4dec48322
fix: update Bark FFI bindings (#2400)
fix: update bark ffi bindings

Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
2026-06-05 13:01:04 +07:00
René Aaron
d2cd4a5ed2
fix: change first payment checklist item to receive-only (#2393) (#2394)
Some checks are pending
Multiplatform Docker build & push / build (push) Waiting to run
Code quality - linting and typechecking / linting (push) Waiting to run
Backend testing with Postgres / test-postgres (push) Waiting to run
Renames the onboarding checklist item from "Send or receive your
first payment" to "Receive your first payment" and marks it complete
when the wallet has a spendable balance (e.g. after opening an
outbound channel) in addition to having a transaction.
2026-06-04 17:45:42 +07:00
René Aaron
d36f050a11
feat: show progress while exporting transactions (#2396)
Exporting a wallet with thousands of transactions previously appeared to
do nothing — the handler silently paginated through every page with no
feedback. It now shows a loading toast that updates with the running
transaction count, and fetches 1000 transactions per page instead of 20
to cut round-trips.

Closes #2386
2026-06-04 17:36:47 +07:00
Roland
df67a2c24e
fix: link Windows CNG libs for bark FFI (#2397)
* fix: link Windows CNG libs for bark FFI

The bark FFI static library is built for the GNU/mingw target and embeds
Rust's getrandom/ring code, which references Windows CNG symbols such as
BCryptGenRandom. The upstream bark bindings only link -lbark_ffi_go, so the
mingw linker fails with "undefined reference to BCryptGenRandom".

cgo merges LDFLAGS across packages, so supply the missing Windows system
libraries from our own bark package without modifying the vendored module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: move cgo directive out of Go doc comment for bark windows

The descriptive comment block was contiguous with the import "C" line, so
the entire block became the cgo C preamble and the C compiler tried to parse
the prose (unknown type name 'The', stray quotes/backticks). Separate the Go
documentation from the cgo preamble with a blank line and keep only the #cgo
directive in a /* */ block immediately preceding import "C".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: link Windows CNG libs for bark via extldflags

cgo #cgo LDFLAGS directives from our package are ordered before the bark
module on the link line, so the single-pass mingw linker discards -lbcrypt
before it sees the undefined BCryptGenRandom reference from libbark_ffi_go.a.

Append the Windows system libraries via -extldflags instead, which places
them after -lbark_ffi_go so the linker can resolve the symbols.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: link bark Windows CNG libs via CGO_LDFLAGS start-group

wails drops -ldflags=-extldflags, so the system libraries never reached the
external linker. Set them through CGO_LDFLAGS instead (read directly by cgo)
and wrap them with bark in a --start-group, so the linker re-scans the group
and resolves BCryptGenRandom regardless of library order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: gate bark backend to platforms with prebuilt FFI libs

The bark FFI bindings ship no native library for 32-bit ARM Linux, so the
armv6 build failed to link bark's own FFI symbols. Constrain the real bark
implementation to bark's supported platforms (darwin/linux amd64+arm64,
windows amd64) and add a stub for everything else that returns an
"unsupported" error if the bark backend is selected at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:20:04 +07:00
dependabot[bot]
f25ce761ac
build(deps): bump github.com/btcsuite/btcd/btcec/v2 from 2.3.6 to 2.5.0 (#2347)
Bumps [github.com/btcsuite/btcd/btcec/v2](https://github.com/btcsuite/btcd) from 2.3.6 to 2.5.0.
- [Release notes](https://github.com/btcsuite/btcd/releases)
- [Changelog](https://github.com/btcsuite/btcd/blob/master/CHANGES)
- [Commits](https://github.com/btcsuite/btcd/compare/btcec/v2.3.6...btcec/v2.5.0)

---
updated-dependencies:
- dependency-name: github.com/btcsuite/btcd/btcec/v2
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:06:31 +07:00
hermes-alby
804075faff
ci: align Wails CLI version (#2398)
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
2026-06-04 16:17:43 +07:00
dependabot[bot]
1f647482a3
build(deps): bump golang.org/x/crypto from 0.50.0 to 0.52.0 (#2348)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.50.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 16:09:59 +07:00
René Aaron
2af517d577
chore: remove redundant Alby Account, Go and Extension home cards (#2392)
These promo cards are now covered by the Stories widget (Alby account,
Alby Go and Alby Extension stories are live in the stories feed), so the
standalone home widgets are redundant.

Supersedes #2175, which patched the older inline cards that were since
refactored into widgets.
2026-06-04 16:05:12 +07:00
dependabot[bot]
fa3d516d02
build(deps): bump github.com/BoltzExchange/boltz-client/v2 from 2.11.3 to 2.12.0 (#2349)
build(deps): bump github.com/BoltzExchange/boltz-client/v2

Bumps [github.com/BoltzExchange/boltz-client/v2](https://github.com/BoltzExchange/boltz-client) from 2.11.3 to 2.12.0.
- [Release notes](https://github.com/BoltzExchange/boltz-client/releases)
- [Changelog](https://github.com/BoltzExchange/boltz-client/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BoltzExchange/boltz-client/compare/v2.11.3...v2.12.0)

---
updated-dependencies:
- dependency-name: github.com/BoltzExchange/boltz-client/v2
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 16:03:38 +07:00
dependabot[bot]
d03d70c6c9
build(deps): bump github.com/btcsuite/btcd/btcutil from 1.1.6 to 1.2.0 (#2350)
Bumps [github.com/btcsuite/btcd/btcutil](https://github.com/btcsuite/btcd) from 1.1.6 to 1.2.0.
- [Release notes](https://github.com/btcsuite/btcd/releases)
- [Changelog](https://github.com/btcsuite/btcd/blob/master/CHANGES)
- [Commits](https://github.com/btcsuite/btcd/compare/btcutil/v1.1.6...btcutil/v1.2.0)

---
updated-dependencies:
- dependency-name: github.com/btcsuite/btcd/btcutil
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 16:01:00 +07:00
dependabot[bot]
e042e01fb7
build(deps-dev): bump typescript-eslint from 8.59.2 to 8.60.1 in /frontend (#2352)
build(deps-dev): bump typescript-eslint in /frontend

Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.59.2 to 8.60.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.59.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 15:25:36 +07:00
dependabot[bot]
feb4aef935
build(deps-dev): bump @vitejs/plugin-react-swc from 4.3.0 to 4.3.1 in /frontend (#2353)
build(deps-dev): bump @vitejs/plugin-react-swc in /frontend

Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.1/packages/plugin-react-swc)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 15:13:52 +07:00
dependabot[bot]
89deef164d
build(deps-dev): bump @types/node from 25.6.2 to 25.8.0 in /frontend (#2355)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.2 to 25.8.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 14:52:39 +07:00
dependabot[bot]
94332c617d
build(deps-dev): bump tailwindcss from 4.2.4 to 4.3.0 in /frontend (#2356)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.4 to 4.3.0.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 14:48:54 +07:00
Michael Bumann
bc49cb7d33
fix: stop retrying NIP47 info publish for deleted apps (#2391)
The NIP47 info publish queue re-enqueued every failed publish with an
incrementing backoff and no terminal condition. When an app connection
was deleted, PublishNip47Info fails the `db.First(&app, appId)` lookup
with gorm.ErrRecordNotFound on every attempt, so the item was retried
forever (observed as a steady stream of "Failed to publish NIP47 info
from queue" errors from affected instances).

Drop the queue item when the app no longer exists instead of requeuing.
All other errors (offline relay, timeouts) still retry with backoff.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:29:05 +07:00
Roland
84e56a23db
feat: bark backend (#2374)
* feat: bark backend

* fix: use real preimage

* fix: balance shows as 0 on startup after hours offline period

* chore: add bark-specific warnings to setup security page

* chore: update setup security page

* chore: use notifications instead of polling

* feat: bark sub-wallet support

* fix: tests

* feat: add env variables for mainnet support

* chore: add custom node commands for debugging, log next notification

* fix: notification handling

* fix: fees for outgoing payments, pending payment handling, decrease required tx confirmations

* chore: bump bark version

* chore: use better bark image

* fix: back button logic and imported mnemonic state in onboarding flow

* chore: update bark copy on security page

* chore: improve copy for first transaction checklist item

* fix: backup copy based on backend

* fix: remove unused/unnecessary code
2026-06-04 11:57:29 +07:00
René Aaron
56b1ce895f
feat: add Pi agent to AI page (#2388)
Some checks failed
Multiplatform Docker build & push / build (push) Has been cancelled
Code quality - linting and typechecking / linting (push) Has been cancelled
Backend testing with Postgres / test-postgres (push) Has been cancelled
Closes #2387
2026-06-02 17:15:29 +07:00
saunter
23dccc6c6f
feat: stories (#2172)
* feat: integrate Stories widget with backend endpoint

Add stories endpoint plumbing for HTTP and Wails, wire the Home Stories card
to fetch from /api/alby/stories, and keep it first in the right column.

Made-with: Cursor

* feat(home): story modal CTAs and preview fallback

- Add contextual actions in the story dialog (update hub with version,
  open Alby Go in-app, install extension) keyed by kind or title
- Use preview stories when the stories API request fails
- Pass hub version from useInfo into the update link

Made-with: Cursor

* feat(stories): polish modal, drop preview fallback

- Widen modal and put video edge-to-edge with overlay close button
- Drop verbose header and 'Watch on YouTube' button
- Remove previewStories fallback so widget hides until upstream API ships
- Tighten title line-height

* feat(stories): render cta from API instead of mapping by kind

Move CTA copy and URLs into the API response. Hub renders story.cta
directly, so adding new story types no longer requires a hub release.

* chore(csp): allow cdn.getalby-assets.com in img-src

* feat(stories): bump avatar size and add ring gap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): post-review cleanups

- Use react-router Link for in-tab CTA instead of plain <a>.
- Drop redundant www.youtube.com from frame-src (embeds always go through nocookie).
- Tighten stories endpoint status check from >= 300 to >= 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): address CodeRabbit feedback

- Switch StoriesWidget to useSWR + swrFetcher (project convention).
- Guard story iframe with isYouTubeUrl so non-YouTube urls never embed.
- Wrap GetStories errors with fmt.Errorf("...: %w", err).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): drop isYouTubeUrl guard

Stories are curated and always YouTube; the runtime check was
redundant. CSP frame-src still constrains the iframe source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): drop getYouTubeEmbedUrl, embed videoUrl as-is

The Alby API now sends canonical youtube-nocookie embed URLs with
autoplay/rel query strings (getAlby/getalby.com#2568), so the
runtime normalization is no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): use w-16 instead of arbitrary w-[73px]

Match the avatar's size token; no magic numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): take videoId from API and assemble embed url locally

Pairs with getAlby/getalby.com#2568. The API now sends just the
YouTube videoId; the hub composes the canonical embed URL so the
domain/query-string format stays in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): treat 3xx as non-success, matching file convention

The other status checks in alby_oauth_service.go all use >= 300;
align GetStories so redirects don't slip through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): move viewed-storage key to constants, widen story button

Address review feedback:
- Centralize the localStorage key for viewed stories in localStorageKeys
  alongside the other keys.
- Widen the story button from w-16 to w-20 so "Alby Extension" fits on
  one line and matches the other titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(stories): bump story button to w-24 so titles fit one line

w-20 still wrapped "Alby Extension"; w-24 fits all current titles
without truncation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "chore(stories): bump story button to w-24 so titles fit one line"

This reverts commit 0b47438f50.

* chore(stories): split title words onto separate lines

Reserve two lines for every story title so avatars align regardless of
title length.

* chore(stories): align homeStoriesViewed key with sibling pattern

* chore(stories): fit titles on one line

* chore(stories): widen story button to w-21 for one-line titles

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 10:50:15 +02:00
René Aaron
81b1b2f695
fix: prevent scrollbar on incoming capacity page (#2383) 2026-05-29 14:11:09 +02:00
saunter
efee2b85e8
feat: new currency input (#2320)
* feat: add currency input to receive flows

* feat: use currency input in send flows (#2321)

* feat: use currency input in send flows

* feat: use currency input in swap flows (#2322)

* feat: use currency input in swap flows

* feat: add BTC denomination toggle to currency input (#2367)

* feat: add BTC denomination toggle to currency input

* fix: auto switch decimal bitcoin input to BTC

* chore: address feedback on currency input field (#2371)

* chore: address feedback on currency input field

* feat: make currency input units clickable

* fix: separate currency and unit click targets

* fix: remove persistent unit toggle highlight

* fix: tighten currency input unit spacing

* fix: make alternate bitcoin amount clickable

* fix: align context amount unit spacing

---------

Co-authored-by: saunter <68239231+stackingsaunter@users.noreply.github.com>

---------

Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com>

---------

Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com>

---------

Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com>

* fix: tab highlight on currency input field buttons

* fix: undo incorrect copy change

* chore: undo unrelated change

* fix: limit min/max validation to 2 decimal places

* fix: input max amounts and context rows based on whether node has channel management

* fix: rename spending balance to lightning balance

* fix: rename spending balance to lightning balance

* fix: re-add anchor reserve alert to swap page

* fix: remove autocomplete from currency input field

* fix: number of decimals in getModeBound

* fix: remove important tailwind modifier

---------

Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com>
Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-05-27 23:11:45 +07:00
René Aaron
48ab9efe2c chore: use redotpay referral link on cards page 2026-05-26 14:21:03 +02:00
Roland
318c622887
chore: simplify kyc info on cards page (#2378) 2026-05-26 09:38:25 +02:00
René Aaron
e29fe81eb4
fix: stop prompting for bitcoin: protocol handler on every load (#2369)
* fix: stop prompting for bitcoin: protocol handler on every load

Browsers re-show the registerProtocolHandler prompt every time it is
called if the user dismissed (X'd) the previous one without explicitly
accepting or denying. Gate the call with sessionStorage so we ask at
most once per browser session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: guard sessionStorage access against restricted/private modes

sessionStorage.getItem and setItem can throw in private browsing or
restricted storage modes. Move both inside the existing try/catch so an
exception doesn't break the hook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: simplify protocol handler session flag to a boolean

sessionStorage is tab-scoped and ephemeral, so comparing the stored
handler URL gains nothing over a plain truthy check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:38:29 +05:30
Roland
e3373474f8
chore: remove json tags from lnclient models (#2375)
* chore: remove json tags from lnclient models

these should not be passed through the API directly

* fix: properly return not implemented errors

* fix: json tags on TLVRecord
2026-05-25 20:26:07 +05:30
Roland
4053a4a722
Fix: card page mobile interface (#2377)
* chore: remove unnecessary regions

* chore: improve cards mobile UI, remove extra regions from RedotPay

* fix: add visit button for mobile provider cards rather than the whole card opening the provider url

* chore(cards): use Select for the region filter everywhere

Drop the mobile-only Select / desktop-only ToggleGroup split. Using a
single Select for the region filter across both viewports removes the
duplicated component, keeps the filter bar a single row at all widths,
and lets the feature toggles stay as pills (those carry icons and read
as a row of binary on/off filters).

* chore(cards): cluster Apple/Google Pay icons next to region badges

Drop the ml-auto on the mobile ProviderCard's pay icons so they sit
right after the region badges instead of floating at the far-right
edge with a large gap. Reads as a single group of card properties.

* chore(cards): rework mobile card spacing + use w-40 for region select

- Bump card padding to p-5 for more breathing room.
- Add a subtle border-t before the facts grid so KYC/Time/Cost/Fees
  read as a separate block from the header + regions row.
- Widen the facts grid's vertical gap (gap-y-4) so labels don't sit
  right against the value of the previous row.
- More space before the Visit CTA (mt-6) so it reads as a primary
  action, not a fifth fact.
- Swap w-[160px] on the region Select for w-40 per project Tailwind
  conventions (CodeRabbit nit on PR #2377).

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
2026-05-24 22:44:26 +02:00
René Aaron
40dcf11a2b
feat(cards): provider directory routed through the standard app-install flow (#2366)
* feat: add cards directory page with provider listings

Adds a dedicated Cards screen that surfaces crypto debit card
providers users can top up from their Alby Hub balance, with region
and feature filters and a fees comparison table.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(cards): connect-card flow with NWC top-up link

Iterates on the cards directory page (#ae087d0b) to wire up the full
connect-card flow described with the bitcoin-card-topup PWA at
card.albylabs.com.

- AI-style hero with three steps: Get a card → Connect it → 1-click top-ups
- Trimmed provider list to RedotPay / 2fiat / Freedomia (the providers
  we've validated end-to-end)
- New "Time to get" column so users see physical vs virtual at a glance
- Renamed "Add card" → "Connect card" everywhere; submit mints a real
  NWC connection via createApp (same pattern as the AI page) with
  scopes for the topup app (get_info / get_balance / list_transactions /
  lookup_invoice / make_invoice / pay_invoice / notifications)
- CardCreatedDialog shown once at creation with QR code + bookmarkable
  top-up link of the form

    https://card.albylabs.com/#label=...&address=0x...&chainId=42161&currency=USDC&nwc=<pairing-uri>

  Includes prominent "save this link — you won't see it again" warning
  (Alert with warning variant) and "scan with your phone's camera app
  (this is a URL, not a Lightning invoice)" caption under the QR
- Saved card tiles link to /apps/:appId so users get back to the NWC
  connection detail; no separate top-up affordance from the hub
- useUserCards hook persists cards in localStorage with appId pointing
  at the NWC connection; ready to migrate to deriving the list from
  /api/apps filtered by metadata.app_store_app_id = "bitcoin-card-topup"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(cards): derive cards from /api/apps, polish

Replaces the localStorage card store with a derivation from /api/apps
filtered by metadata.app_store_app_id = "bitcoin-card-topup". Card
provider/destination/chain/currency now ride on the NWC app's metadata,
so cards survive across devices, reloads and DB backups, and "forget
card" flows through the existing /apps/:id delete affordance.

- Gate /cards through DefaultRedirect so a locked hub redirects to
  /unlock (matches /wallet, /apps, etc.)
- Hub-generated top-up link now uses #?... prefix so bitcoin-connect's
  parser branches to the URLSearchParams path (works in all cases vs.
  the bare #... which mis-parsed for some users)
- Remove EmptyCards empty state (the Connect card button in the page
  header is the sole entry point)
- Drop the "Experimental" filter and badge — too small a catalog for
  it to be useful, and the toggle behavior confused users
- Replace "Top up via" column with "Card cost" (more decision-relevant)
- Verified all provider data from each provider's site; updated
  - RedotPay: KYC Full (not Light), regions add US/UK, fees ~2.2% + FX
  - 2fiat: Mastercard (not Visa), Apple Pay + Google Pay supported, KYC
    None (not Light), card cost $50, fees ~6.8%
  - Freedomia: Google Pay supported, card cost $5–30/mo subscription,
    fees 1.3–4.3%
- Add a hoverable info-icon tooltip on the "None" KYC badge so privacy-
  focused users keep the signal they want while cautious users see the
  merchant-of-record-fragility caveat
- Field label "Destination address" → "Top-up address" (matches what
  the user pastes from their provider)
- Connect dialog copy de-emdashed
- Various smaller copy/layout tweaks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(cards): tidy provider tiles, filter, and column header

- Switch Freedomia tile bg to bg-orange-500 to match the brand's
  orange F logo
- Drop the upscaled watermark logo from card-tile backgrounds for a
  cleaner surface
- Hide filter toggles whose criterion no provider satisfies, so the
  filter bar only offers actionable options
- Rename "Mobile pay" column header to "Mobile" — the icons already
  identify Apple/Google Pay

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(cards): drop unused provider logos

We trimmed the catalog to RedotPay/2fiat/Freedomia in an earlier
commit; the other 8 PNGs were left behind. Remove them to keep the
asset directory tight.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(cards): downgrade RedotPay KYC from Full to Light

RedotPay only requires ID verification — no proof of address, employer
details, or source-of-funds questions. "Full" overstates it and may
deter users from even trying.

Adds a Light KYC tooltip alongside the existing None one so the
distinction is visible at a glance.

* chore(cards): tighten copy and mobile layout

- CardCreatedDialog: drop the "regular URL, not a Lightning invoice"
  caveat and replace with actionable "save it as an app to your
  homescreen" guidance.
- Hide the QR + scan instruction on mobile — the user is already on
  their phone, so scanning their own screen is nonsense.
- Provider table: switch wrapper from overflow-hidden to overflow-x-auto
  and set a 720px min-width so 9 columns scroll horizontally on narrow
  viewports instead of cramming/clipping.

* feat(cards): list Bringin and wavecard

Both already ship as suggested apps with their own NWC pairing flows, so
the table row's action arrow links to /appstore/<id> rather than the
stablecoin connect-card dialog. Marked via a new optional appStoreId
flag on Provider; the Connect-card dropdown filters these out so the
top-up form never offers a chain/currency for cards that don't need one.
Reuses the existing suggested-apps logos to avoid duplicating assets.

* chore(cards): retitle 3rd hero step to highlight top-up speed

"Top up in seconds — your card is funded in under a minute, ready to
spend on the go" lands the speed promise harder than "1-click top-ups"
did, and frames the value as on-the-go spending rather than mechanics.

* feat(cards): route app-store cards through the Connect dialog

Bringin and wavecard are now in the provider dropdown. Picking either
hides the address/network/currency fields and replaces the primary
button with an "Open setup guide" link to /appstore/<id>, where their
own NWC pairing flow lives. Keeps a single entry point for "connect a
card" without forcing the stablecoin form onto cards that don't use it.

Also tightens the hero subtitle to lead with the speed promise.

* chore(cards): link app-store cards straight to /apps/new

Skip the app-store detail page — /apps/new?app=<id> drops users into
the connection flow with the right app preselected, which is what they
actually wanted.

* chore(cards): correct Bringin and wavecard table data

Verified against bringin.app/bitcoin-debit-cards and wave.space/card:

- Both offer physical *and* virtual cards (added "Both" to cardType).
- wave.space is EEA-only for issuance (card itself is accepted globally)
  — regions changed from Global to EU.
- Direct Apple Pay isn't live for either; both currently work via Curve,
  which is too indirect to claim native Apple Pay support.
- Issuance is not free: Bringin charges a €3.49/mo subscription that
  bundles both cards; wave.space charges €2.99 virtual / €29.99 physical
  one-time.
- Conversion fees are 1% + ~0.5% LP spread for both, not flat ~1%.
- Bringin URL fixed to bringin.app (they migrated from bringin.xyz);
  wave.space URL points at the card landing page.

Doesn't change the dialog routing — picking Bringin/wavecard still drops
the user into /apps/new?app=<id> for the NWC pairing.

* chore(cards): tighten created-dialog copy

- Title and subtitle now describe the link instead of warning.
- Alert highlights the device-specific action ("save it on the phone
  you'll top up from") and drops the redundant secret-recovery prose.
- QR caption mentions bookmark as an alternative to home-screen install.

* refactor(cards): route every provider through the standard app flow

- Add a bitcoin-card-topup app store entry pointing to card.albylabs.com
  with a "visit + add to home screen + enter card details" install guide.
- Wire RedotPay and Freedomia to bitcoin-card-topup, and 2fiat to its
  existing app store entry. Bringin and wavespace already had theirs.
- Drop the custom Connect card dialog, address/network/currency form,
  and the one-shot CardCreatedDialog with embedded NWC link. Card config
  is now collected inside the topup app itself.
- Drop the "Your card connections" section and the useUserCards hook —
  connected cards show up in the standard /apps list like any other app.

* feat(cards): add Connect card picker dialog + clickable rows

- Bring back the Connect card button in the header; opens a lightweight
  provider-picker dialog where each tile routes straight to
  /apps/new?app=<appStoreId>.
- Drop the table's action column entirely; the whole provider row is now
  clickable and opens the provider's website in a new tab. This separates
  discovery (row click → learn more) from action (Connect card → setup).

* feat(cards): add Other card option + broaden install wording

- Add an "Other card" tile to the Connect card picker (dashed border,
  generic credit-card icon). Routes to /apps/new?app=bitcoin-card-topup
  so anyone holding a USDC/USDT card not in the listed providers can
  still set up the topup flow.
- Reword the install guide from "phone you'll top up from" to "device" —
  the topup app works equally well on a tablet or any browser.

* chore(cards): fix bitcoin-card-topup logo + broaden copy to "any crypto"

- Swap the placeholder 2fiat logo for the Alby logo (alby.png).
- Drop USDC/USDT specifics from the app description, extended
  description, and the picker dialog's "Other card" tile copy. From the
  user's perspective the topup app just takes a crypto card.

* chore(cards): use bitcoin-card-topup's own PWA icon as the app logo

Copy bitcoin-card-topup/public/shortcut-icon.png (the topup PWA's home-
screen icon) into suggested-apps/ and point the bitcoin-card-topup app
store entry at it, replacing the Alby-logo placeholder.

* fix(apps): align Connect-to-app header logo size with the appstore page

NewApp.tsx rendered the app logo at w-12 h-12 (48px) while
AppStoreDetailHeader uses w-14 h-14 (56px) — the logo visually jumps
between /apps/new?app=<id> and /appstore/<id>. Unify on w-14 h-14.

* fix(appstore): use AppHeader's standard icon/description slots

AppStoreDetailHeader was rendering the logo + title + description inside
a custom flex container nested into AppHeader's title prop, which gave
the icon a different vertical alignment than every other AppHeader use.
Pass them via the icon and description props instead so the header
layout stays consistent with /apps/new and the rest of the app.

* chore(cards): use Freedomia affiliate URL

* chore: add affiliate links

* fix: always show global cards

* fix: make 2fiat lightning native

* chore: update card instructions

* chore: optimize bitcoin card topup app image

* chore: improve bitcoin card topup copy

* chore: add card events, fix http service event endpoint url

* chore(cards): drop redundant 'Get a card' section heading

* chore(cards): tighten hero subtext + 3-step copy, hide filter footer when inactive

- Hero subtext drops the "one click" claim that's no longer accurate
  post-refactor and the em-dash.
- 3-step descriptions reworded for length and fix "Setup" -> "Set up";
  bottom step matches the "in seconds" heading instead of saying
  "under a minute".
- "Showing N of M providers" footer is hidden unless a filter is
  active — silent when there's nothing to clarify.

* chore(cards): apply Alby button gradient + brand stroke to hero card

Use the same vertical white→cream highlight and 1px #ffdf6f stroke that
alby.css applies to default primary buttons, so the hero card visual
reads as part of the same theme family instead of an isolated panel.

* chore(cards): reframe step 2 around the top-up link + drop hero gradient

- Step 2 title shifts from "Connect it" to "Get a top-up link" so the
  deliverable (a saveable link) is what users see, matching what
  actually happens at that step post-refactor.
- Drop the white→cream gradient overlay from the hero card; the flat
  yellow with the existing shadow is the look we want.

* chore: change wave.space kyc level

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
2026-05-24 12:22:48 +07:00
René Aaron
d58b9f3ead
fix: show BOLT-12 offer button for CLN backend (#2360)
* fix: show BOLT-12 offer button for CLN backend

The CLN backend implements MakeOffer and the /api/offers endpoint is
backend-agnostic, but the UI only surfaced the "Lightning Offer" button
when backendType === "LDK", leaving CLN users without a way to reach
the flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: gate BOLT-12 offer UI on supportsBolt12 from useInfo

Surface a supportsBolt12 capability on the info response (true for LDK
and CLN) so the frontend stops hardcoding backend-type checks for the
BOLT-12 offer button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:10:02 +07:00
Roland
3bb36611ae
fix: error log when failing to request delete lightning address endpoint (#2338) 2026-05-23 15:30:13 +07:00
Roland
ee001d16b4
fix: missing error handling in create connection controller (#2335) 2026-05-23 15:29:35 +07:00
hermes-alby
dd04326f76
chore: remove shut down suggested apps (#2372)
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
2026-05-23 15:26:54 +07:00
Roland
4a7123a4b9
Feat: satora rebrand (#2376)
* fix(appstore): rebrand LendaSwap as Satora

* chore: also show the previous name on the app card

---------

Co-authored-by: Lucas Soriano del Pino <lucas_soriano@fastmail.com>
2026-05-23 15:26:33 +07:00
daywalker90
12a3b114ff
CLN balance offer fix and makeoffer fix (#2373)
* fix: don't use deprecated balance fields for CLN

* fix: add missing amount for makeoffer for CLN
2026-05-23 13:55:44 +07:00
René Aaron
3a2f935a75
feat: add Hermes agent to AI page (#2358)
Closes #2357

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:04:35 +02:00
Roland
a0d3da2803
fix: slow app deletion due to unnecessary key derivation (#2342)
* fix: slow app deletion due to unnecessary key derivation

* chore: fix comment grammar

Co-authored-by: Adithya Vardhan <imadithyavardhan@gmail.com>

---------

Co-authored-by: Adithya Vardhan <imadithyavardhan@gmail.com>
2026-05-14 20:34:59 +07:00
Roland
55d665db74
fix: check lnclient is nil in GetPermittedMethods (#2343)
this caused a possible panic on shutdown
2026-05-14 17:07:37 +05:30
Roland
162965300e
docs: add README for keys (#2333) 2026-05-11 19:57:50 +05:30
204 changed files with 19721 additions and 4110 deletions

View file

@ -6,6 +6,8 @@ AUTO_LINK_ALBY_ACCOUNT=false
# Optionally set LDK debug log level to get more info
#LDK_LOG_LEVEL=5
# Optionally set Bark debug log level to get more info
#BARK_LOG_LEVEL=5
# Optionally set Main application debug log level to get more info
#LOG_LEVEL=5

View file

@ -25,6 +25,7 @@ on:
jobs:
build:
strategy:
fail-fast: false
matrix:
build:
[
@ -85,7 +86,7 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "22.x"
- name: Run tests
run: mkdir frontend/dist && touch frontend/dist/tmp && go test ./...
@ -128,7 +129,15 @@ jobs:
run: go build ${{ env.GOTAGS }} -o build/bin/${{ env.PACKAGE_NAME }}/bin/${{ env.EXEC_NAME }} -ldflags "-X 'github.com/getAlby/hub/version.Tag=${{ env.TAG }}'" cmd/http/main.go
- name: Import Code-Signing Certificates for macOS
if: runner.os == 'macOS'
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
uses: Apple-Actions/import-codesign-certs@v3
with:
# The certificates in a PKCS12 file encoded as a base64 string
@ -165,7 +174,15 @@ jobs:
shell: bash
- name: Sign the MacOS binary and libraries
if: runner.os == 'macOS'
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
run: |
/usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime ./build/bin/${{ env.PACKAGE_NAME }}/bin/${{ env.EXEC_NAME }}
/usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime ./build/bin/${{ env.PACKAGE_NAME }}/lib/*.dylib
@ -189,7 +206,16 @@ jobs:
cd ../../..
- name: Notarize the zip file
if: runner.os == 'macOS' && inputs.build-release
if: >-
runner.os == 'macOS' &&
inputs.build-release &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
run: |
echo "Notarizing Zip Files"
gon -log-level=info -log-json ./build/darwin/http/gon-notarize.json

View file

@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20.x
node-version: 22.x
cache: "yarn"
cache-dependency-path: frontend/yarn.lock

View file

@ -73,10 +73,10 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "22.x"
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.14.0
shell: bash
- name: Install Linux Wails deps
@ -129,7 +129,15 @@ jobs:
shell: bash
- name: Import Code-Signing Certificates for macOS
if: runner.os == 'macOS'
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
uses: Apple-Actions/import-codesign-certs@v3
with:
# The certificates in a PKCS12 file encoded as a base64 string
@ -190,7 +198,15 @@ jobs:
mv ./build/out/${{ env.PACKAGE_NAME }}.tar.bz2 ./build/bin/
- name: Sign the macOS binary
if: runner.os == 'macOS'
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
run: |
echo "Signing Package"
/usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime --entitlements ./build/darwin/entitlements.plist "./build/bin/${{ env.EXEC_NAME }}.app"
@ -222,7 +238,15 @@ jobs:
EOF
- name: Notarize the DMG image
if: runner.os == 'macOS'
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
run: |
echo "Notarizing Zip Files"
gon -log-level=info -log-json ./build/darwin/gon-notarize.json

3
.gitignore vendored
View file

@ -30,4 +30,5 @@ glalby
*.db-shm
*.db-wal
*.db-journal
albyhub-data
albyhub-data
.claude/worktrees

View file

@ -88,6 +88,8 @@ go run cmd/http/main.go
wails dev -tags "wails"
```
**Wails versions must stay in sync:** the `github.com/wailsapp/wails/v2` version in `go.mod` and the Wails CLI version installed in `.github/workflows/wails.yml` (`go install ...cmd/wails@vX.Y.Z`) must match. When bumping one, always update the other — this is a common source of drift (e.g. via Dependabot updates to `go.mod` only).
## Testing
### Go Backend
@ -188,6 +190,16 @@ Code under `frontend/platform_specific/http/` and `frontend/platform_specific/wa
- New screens added to `frontend/src/routes.tsx`.
- ESLint + Prettier enforced via pre-commit hooks (husky).
### Branches
Use a type prefix followed by a short, dash-separated summary: `feat/`, `chore/`, or `fix/`. For example:
```text
feat/add-cashu-backend
fix/payment-timeout-crash
chore/bump-go-1.25
```
### Commits
Follow **Conventional Commits** format (`feat:`, `fix:`, `chore:`, etc.) — enforced by commitlint.

View file

@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend
FROM node:22-alpine AS frontend
# Set the base path for the frontend build
# This can be overridden at build time with --build-arg BASE_PATH=<url> e.g. --build-arg BASE_PATH=/hub
@ -9,7 +9,7 @@ COPY frontend ./frontend
RUN echo "Building frontend with base path $BASE_PATH"
RUN cd frontend && yarn install --network-timeout 3000000 && yarn build:http
FROM golang:1.25 AS builder
FROM golang:1.26.2 AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM

View file

@ -14,7 +14,7 @@ The application can run in two modes:
Ideally the app runs 24/7 (on a node, VPS or always-online desktop/laptop machine) so it can be connected to a lightning address and receive online payments.
## Run on Alby Cloud
## Learn more about Alby Hub
Visit [albyhub.com](https://albyhub.com) to learn more and get started and get Alby Hub running in minutes.
@ -207,6 +207,12 @@ Migration of the database is currently experimental. Please make a backup before
go run cmd/db_migrate/main.go -from .data/nwc.db -to postgresql://myuser:mypass@localhost:5432/nwc
#### Migration from Postgres to Sqlite
No manual steps are needed: create a migration file from Settings -> Migrate Alby Hub. The contents of the Postgres database will automatically be copied into a Sqlite database which is included in the migration file. Alternatively, run the migration tool manually:
go run cmd/db_migrate/main.go -from postgresql://myuser:mypass@localhost:5432/nwc -to .data/nwc.db
## Node-specific backend parameters
- `ENABLE_ADVANCED_SETUP`: set to `false` to force a specific backend type (combined with backend parameters below)
@ -220,6 +226,7 @@ Can be configured via env or the UI
- `CLN_LIGHTNING_DIR`: CLN's lightning directory containing the grpc certificates, usually `~/.lightning/<network>`
Optional for hold invoice methods support:
- `CLN_ADDRESS_HOLD`: the CLN hold plugin grpc address (grpc-host and grpc-port), e.g. `127.0.0.1:9738`
If you are copying the certificates to another machine make sure you get the `ca.pem`, `client.pem` and `client-key.pem` from the lightning directory and optionally from the `hold` directory inside the lightning directory and keep the sub-directory structure of the hold directory.
@ -245,6 +252,7 @@ _To configure via env, the following parameters must be provided:_
- `LDK_MAX_PATH_COUNT`: Maximum number of paths that may be used by MPP payments.
- `LDK_LOG_LEVEL`: Log level for the LDK node. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose LDK logging (e.g., level 4, 5 or 6) without enabling verbose logging for the entire application.
- `LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES`: If a channel monitor is larger than this value, a performance warning will be shown on the node page.
- `LDK_LSPS2_ADDRESSES`: Override the LSPS2 just-in-time (JIT) LSP provider for receiving. When set, Alby Hub can receive payments even without inbound liquidity: the configured LSP opens a channel on the fly and the fee is deducted from the incoming payment. Expected format is a single `<pubkey>@<host>:<port>`. When set, the "Open Your First Channel" prompts are hidden since the first channel is created automatically on the first receive.
#### LDK Network Configuration
@ -283,6 +291,16 @@ _To configure via env, the following parameters must be provided:_
See [Phoenixd](scripts/linux-x86_64/phoenixd/README.md)
### Bark
Bark connects to an [Ark](https://second.tech/) server. It can be configured via env.
- `LN_BACKEND_TYPE`: BARK
- `BARK_SERVER`: the Ark server URL. For signet use `https://ark.signet.2nd.dev`
- `BARK_ESPLORA_SERVER`: the Esplora server URL used for chain data. For signet use `https://esplora.signet.2nd.dev`.
- `BARK_SERVER_ACCESS_TOKEN`: an optional access token, only required if using a private Ark server.
- `BARK_LOG_LEVEL`: Log level for Bark. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose Bark logging (e.g., level 4 or 5) without enabling verbose logging for the entire application.
### Alby OAuth
Create an OAuth client at the [Alby Developer Portal](https://getalby.com/developer) and set your `ALBY_OAUTH_CLIENT_ID` and `ALBY_OAUTH_CLIENT_SECRET` in your .env. If not running locally, you'll also need to change your `BASE_URL`.
@ -384,6 +402,8 @@ Once the user has authorized the app connection a `nwc:success` message is sent
If you need help contact support@getalby.com or reach out on Nostr: npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm
You can also visit the chat of our Community on [Telegram](https://t.me/getalby).
For security vulnerabilities, please follow our [security policy](SECURITY.md).
## ⚡Donations
Want to support the work on Alby?

7
SECURITY.md Normal file
View file

@ -0,0 +1,7 @@
# Security Policy
## Reporting a Vulnerability
Please report suspected security vulnerabilities privately by emailing [security@getalby.com](mailto:security@getalby.com). Do not open a public issue or disclose the vulnerability publicly until we have coordinated a fix.
Please include the affected version or component, the potential impact, and clear steps to reproduce the issue. We will acknowledge your report and keep you informed as we investigate and address it.

View file

@ -408,7 +408,7 @@ func (svc *albyOAuthService) DeleteLightningAddress(ctx context.Context, address
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to delete lightning address endpoint")
logger.Logger.WithError(err).Error("Failed to request delete lightning address endpoint")
return err
}
@ -418,6 +418,13 @@ func (svc *albyOAuthService) DeleteLightningAddress(ctx context.Context, address
return errors.New("failed to read response body")
}
if res.StatusCode == http.StatusNotFound {
// The lightning address was already deleted on the Alby account
// (e.g. removed previously). Treat as success so deletion is idempotent.
logger.Logger.WithField("address", address).Info("Lightning address already deleted on Alby account, ignoring 404")
return nil
}
if res.StatusCode >= 300 {
return fmt.Errorf("DELETE request to /internal/lightning_addresses/%s returned non-success status: %d %s", address, res.StatusCode, string(responseBody))
}
@ -1369,6 +1376,52 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string,
}, nil
}
func (svc *albyOAuthService) GetStories(ctx context.Context) ([]Story, error) {
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("%s/stories", albyInternalAPIURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
logger.Logger.WithError(err).Error("Error creating request to stories endpoint")
return nil, fmt.Errorf("create stories request: %w", err)
}
setDefaultRequestHeaders(req)
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch stories from API")
return nil, fmt.Errorf("fetch stories: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to read response body")
return nil, fmt.Errorf("read stories response body: %w", err)
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"status_code": res.StatusCode,
}).Error("stories endpoint returned non-success code")
return nil, fmt.Errorf("stories endpoint returned %d: %s", res.StatusCode, string(body))
}
var stories []Story
if err := json.Unmarshal(body, &stories); err != nil {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"error": err,
}).Error("Failed to decode stories API response")
return nil, fmt.Errorf("decode stories response: %w", err)
}
return stories, nil
}
func setDefaultRequestHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "AlbyHub/"+version.Tag)
@ -1413,5 +1466,7 @@ func getEventWhitelist() []string {
// client-side events
"payment_failed_details",
"debit_card_url_clicked",
"debit_card_connect",
}
}

View file

@ -32,6 +32,7 @@ type AlbyOAuthService interface {
RemoveOAuthAccessToken() error
CreateLightningAddress(ctx context.Context, address string, appId uint) (*CreateLightningAddressResponse, error)
DeleteLightningAddress(ctx context.Context, address string) error
GetStories(ctx context.Context) ([]Story, error)
}
type CreateLightningAddressResponse struct {
@ -118,6 +119,7 @@ type ChannelPeerSuggestion struct {
Description string `json:"description"`
Note string `json:"note"`
PublicChannelsAllowed bool `json:"publicChannelsAllowed"`
NodeAddress string `json:"nodeAddress"`
FeeTotalSat1m *uint32 `json:"feeTotalSat1m"`
FeeTotalSat2m *uint32 `json:"feeTotalSat2m"`
FeeTotalSat3m *uint32 `json:"feeTotalSat3m"`
@ -153,6 +155,20 @@ type ErrorResponse struct {
Message string `json:"message"`
}
type StoryCta struct {
Label string `json:"label"`
URL string `json:"url"`
OpenInNewTab bool `json:"openInNewTab"`
}
type Story struct {
ID int `json:"id"`
Title string `json:"title"`
Avatar string `json:"avatar"`
VideoID string `json:"videoId,omitempty"`
Cta *StoryCta `json:"cta,omitempty"`
}
type LSPChannelPaymentBolt11 struct {
Invoice string `json:"invoice"`
FeeTotalSat string `json:"fee_total_sat"`

View file

@ -2,8 +2,11 @@ package api
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
@ -11,13 +14,16 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/sirupsen/logrus"
"gopkg.in/macaroon.v2"
"gorm.io/datatypes"
"gorm.io/gorm"
@ -50,6 +56,9 @@ type api struct {
startupError error
startupErrorTime time.Time
eventPublisher events.EventPublisher
// set after a migration file is created; the hub is halted at that point
// and the frontend should keep showing the migration success page
nodeMigrationFileCreated atomic.Bool
}
func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
@ -126,21 +135,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
responseBody.RelayUrls = relayUrls
responseBody.Lud16 = lightningAddress
if createAppRequest.ReturnTo != "" {
returnToUrl, err := url.Parse(createAppRequest.ReturnTo)
if err == nil {
query := returnToUrl.Query()
for _, relayUrl := range relayUrls {
query.Add("relay", relayUrl)
}
query.Add("pubkey", *app.WalletPubkey)
if lightningAddress != "" && !app.Isolated {
query.Add("lud16", lightningAddress)
}
returnToUrl.RawQuery = query.Encode()
responseBody.ReturnTo = returnToUrl.String()
}
}
responseBody.ReturnTo = buildReturnToUrl(createAppRequest.ReturnTo, relayUrls, *app.WalletPubkey, lightningAddress, app.Isolated)
var lud16 string
if lightningAddress != "" && !app.Isolated {
@ -151,6 +146,28 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
return responseBody, nil
}
// buildReturnToUrl adds the connection query parameters to the return_to
// URL the user will be redirected to. Only http and https URLs are accepted.
func buildReturnToUrl(returnTo string, relayUrls []string, walletPubkey string, lightningAddress string, isolated bool) string {
if returnTo == "" {
return ""
}
returnToUrl, err := url.Parse(returnTo)
if err != nil || (returnToUrl.Scheme != "http" && returnToUrl.Scheme != "https") {
return ""
}
query := returnToUrl.Query()
for _, relayUrl := range relayUrls {
query.Add("relay", relayUrl)
}
query.Add("pubkey", walletPubkey)
if lightningAddress != "" && !isolated {
query.Add("lud16", lightningAddress)
}
returnToUrl.RawQuery = query.Encode()
return returnToUrl.String()
}
func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
resolvedMaxAmountSat := ResolveToSat(updateAppRequest.MaxAmountSat, updateAppRequest.MaxAmountMsat, updateAppRequest.MaxAmount, nil)
@ -756,6 +773,10 @@ func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPe
return api.albySvc.GetChannelPeerSuggestions(ctx)
}
func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) {
return api.albyOAuthSvc.GetStories(ctx)
}
func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) {
return api.albyOAuthSvc.GetLSPChannelOffer(ctx)
}
@ -834,12 +855,20 @@ func (api *api) Stop() error {
return nil
}
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) {
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
return lnClient.GetNodeConnectionInfo(ctx)
info, err := lnClient.GetNodeConnectionInfo(ctx)
if err != nil {
return nil, err
}
return &NodeConnectionInfo{
Pubkey: info.Pubkey,
Address: info.Address,
Port: info.Port,
}, nil
}
func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error {
@ -1127,20 +1156,47 @@ func (api *api) GetSwapMnemonic() string {
return api.keys.GetSwapMnemonic()
}
func (api *api) GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) {
func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
return lnClient.GetNodeStatus(ctx)
nodeStatus, err := lnClient.GetNodeStatus(ctx)
if err != nil {
return nil, err
}
if nodeStatus == nil {
return nil, nil
}
return toApiNodeStatus(nodeStatus), nil
}
func (api *api) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus {
return &NodeStatus{
IsReady: nodeStatus.IsReady,
InternalNodeStatus: nodeStatus.InternalNodeStatus,
}
}
func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
return lnClient.ListPeers(ctx)
peers, err := lnClient.ListPeers(ctx)
if err != nil {
return nil, err
}
apiPeers := make([]PeerDetails, 0, len(peers))
for _, peer := range peers {
apiPeers = append(apiPeers, PeerDetails{
NodeId: peer.NodeId,
Address: peer.Address,
IsPersisted: peer.IsPersisted,
IsConnected: peer.IsConnected,
})
}
return apiPeers, nil
}
func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error {
@ -1148,7 +1204,11 @@ func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeer
if lnClient == nil {
return ErrLNClientNotStarted
}
return lnClient.ConnectPeer(ctx, connectPeerRequest)
return lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
Pubkey: connectPeerRequest.Pubkey,
Address: connectPeerRequest.Address,
Port: connectPeerRequest.Port,
})
}
func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) {
@ -1156,7 +1216,17 @@ func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannel
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
return lnClient.OpenChannel(ctx, openChannelRequest)
resp, err := lnClient.OpenChannel(ctx, &lnclient.OpenChannelRequest{
Pubkey: openChannelRequest.Pubkey,
AmountSats: openChannelRequest.AmountSats,
Public: openChannelRequest.Public,
})
if err != nil {
return nil, err
}
return &OpenChannelResponse{
FundingTxId: resp.FundingTxId,
}, nil
}
func (api *api) DisconnectPeer(ctx context.Context, peerId string) error {
@ -1180,11 +1250,15 @@ func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, forc
"channel_id": channelId,
"force": force,
}).Info("Closing channel")
return lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
err := lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
NodeId: peerId,
ChannelId: channelId,
Force: force,
})
if err != nil {
return nil, err
}
return &CloseChannelResponse{}, nil
}
func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error {
@ -1195,7 +1269,13 @@ func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateC
logger.Logger.WithFields(logrus.Fields{
"request": updateChannelRequest,
}).Info("updating channel")
return lnClient.UpdateChannel(ctx, updateChannelRequest)
return lnClient.UpdateChannel(ctx, &lnclient.UpdateChannelRequest{
ChannelId: updateChannelRequest.ChannelId,
NodeId: updateChannelRequest.NodeId,
ForwardingFeeBaseMsat: updateChannelRequest.ForwardingFeeBaseMsat,
ForwardingFeeProportionalMillionths: updateChannelRequest.ForwardingFeeProportionalMillionths,
MaxDustHtlcExposureFromFeeRateMultiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier,
})
}
func (api *api) MakeOffer(ctx context.Context, description string) (string, error) {
@ -1306,7 +1386,70 @@ func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) {
if err != nil {
return nil, err
}
return balances, nil
return toApiBalances(balances), nil
}
func toApiBalances(balances *lnclient.BalancesResponse) *BalancesResponse {
totalSpendableMsat := balances.Lightning.TotalSpendableMsat
totalReceivableMsat := balances.Lightning.TotalReceivableMsat
nextMaxSpendableMsat := balances.Lightning.NextMaxSpendableMsat
nextMaxReceivableMsat := balances.Lightning.NextMaxReceivableMsat
nextMaxSpendableMPPMsat := balances.Lightning.NextMaxSpendableMPPMsat
nextMaxReceivableMPPMsat := balances.Lightning.NextMaxReceivableMPPMsat
return &BalancesResponse{
Onchain: OnchainBalanceResponse{
Spendable: balances.Onchain.SpendableSat,
SpendableSat: balances.Onchain.SpendableSat,
Total: balances.Onchain.TotalSat,
TotalSat: balances.Onchain.TotalSat,
Reserved: balances.Onchain.ReservedSat,
ReservedSat: balances.Onchain.ReservedSat,
PendingBalancesFromChannelClosures: balances.Onchain.PendingBalancesFromChannelClosuresSat,
PendingBalancesFromChannelClosuresSat: balances.Onchain.PendingBalancesFromChannelClosuresSat,
PendingBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingBalancesDetails),
PendingSweepBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingSweepBalancesDetails),
InternalBalances: balances.Onchain.InternalBalances,
},
Lightning: LightningBalanceResponse{
TotalSpendable: totalSpendableMsat,
TotalSpendableSat: totalSpendableMsat / 1000,
TotalSpendableMsat: totalSpendableMsat,
TotalReceivable: totalReceivableMsat,
TotalReceivableSat: totalReceivableMsat / 1000,
TotalReceivableMsat: totalReceivableMsat,
NextMaxSpendable: nextMaxSpendableMsat,
NextMaxSpendableSat: nextMaxSpendableMsat / 1000,
NextMaxSpendableMsat: nextMaxSpendableMsat,
NextMaxReceivable: nextMaxReceivableMsat,
NextMaxReceivableSat: nextMaxReceivableMsat / 1000,
NextMaxReceivableMsat: nextMaxReceivableMsat,
NextMaxSpendableMPP: nextMaxSpendableMPPMsat,
NextMaxSpendableMPPSat: nextMaxSpendableMPPMsat / 1000,
NextMaxSpendableMPPMsat: nextMaxSpendableMPPMsat,
NextMaxReceivableMPP: nextMaxReceivableMPPMsat,
NextMaxReceivableMPPSat: nextMaxReceivableMPPMsat / 1000,
NextMaxReceivableMPPMsat: nextMaxReceivableMPPMsat,
},
}
}
func toApiPendingBalanceDetails(details []lnclient.PendingBalanceDetails) []PendingBalanceDetails {
if details == nil {
return nil
}
apiDetails := make([]PendingBalanceDetails, 0, len(details))
for _, d := range details {
apiDetails = append(apiDetails, PendingBalanceDetails{
ChannelId: d.ChannelId,
NodeId: d.NodeId,
Amount: d.AmountSat,
AmountSat: d.AmountSat,
FundingTxId: d.FundingTxId,
FundingTxVout: d.FundingTxVout,
})
}
return apiDetails
}
// TODO: remove dependency on this endpoint
@ -1365,8 +1508,22 @@ func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interfa
func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
info := InfoResponse{}
if api.nodeMigrationFileCreated.Load() {
// the hub is halted and the database is closed after a migration file
// is created, so return a minimal response without reading any config
// or node state; the frontend only needs the flag to keep showing the
// migration success page
info.NodeMigrationFileCreated = true
info.SetupCompleted = true
info.Version = version.Tag
info.Relays = []InfoResponseRelay{}
return &info, nil
}
backendType, _ := api.cfg.Get("LNBackendType", "")
ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "")
autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "")
setupCompleted, err := api.cfg.SetupCompleted()
if err != nil {
@ -1383,6 +1540,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
}
lnClient := api.svc.GetLNClient()
info.Running = lnClient != nil
info.NodeMigrationFileCreated = api.nodeMigrationFileCreated.Load()
info.BackendType = backendType
info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl()
info.OAuthRedirect = !api.cfg.GetEnv().IsDefaultClientId()
@ -1390,7 +1548,11 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup
info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner
info.LdkVssEnabled = ldkVssEnabled == "true"
info.JitChannelsEnabled = jitChannelsEnabled != "false"
info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != ""
info.LdkVssUrl = api.cfg.GetEnv().LDKVssUrl
info.DatabaseType = api.db.Dialector.Name()
info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType
info.AutoUnlockPasswordEnabled = autoUnlockPassword != ""
info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId()
info.Relays = []InfoResponseRelay{}
@ -1425,10 +1587,28 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
type chainSourceProvider interface {
GetChainDataSource() (string, string)
}
type lsps2SourceProvider interface {
GetLiquiditySourceLsps2() string
}
type lsps2MinPaymentSizeProvider interface {
GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64
}
type lsps2MaxPaymentSizeProvider interface {
GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64
}
if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok {
info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource()
}
if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok {
info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2()
}
if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok {
info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat()
}
if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok {
info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat()
}
}
}
@ -1439,7 +1619,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
return &info, nil
}
func (api *api) SetCurrency(currency string) error {
func (api *api) setCurrency(currency string) error {
if currency == "" {
return fmt.Errorf("currency value cannot be empty")
}
@ -1453,7 +1633,7 @@ func (api *api) SetCurrency(currency string) error {
return nil
}
func (api *api) SetBitcoinDisplayFormat(format string) error {
func (api *api) setBitcoinDisplayFormat(format string) error {
if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
}
@ -1467,21 +1647,43 @@ func (api *api) SetBitcoinDisplayFormat(format string) error {
return nil
}
func (api *api) setJitChannelsEnabled(enabled bool) error {
value := "true"
if !enabled {
value = "false"
}
err := api.cfg.SetUpdate("JitChannelsEnabled", value, "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to update JIT channels setting")
return err
}
return nil
}
func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error {
if updateSettingsRequest.Currency != "" {
err := api.SetCurrency(updateSettingsRequest.Currency)
err := api.setCurrency(updateSettingsRequest.Currency)
if err != nil {
return fmt.Errorf("failed to set currency: %w", err)
}
}
if updateSettingsRequest.BitcoinDisplayFormat != "" {
err := api.SetBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
if err != nil {
return fmt.Errorf("failed to set bitcoin display format: %w", err)
}
}
if updateSettingsRequest.JitChannelsEnabled != nil {
err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled)
if err != nil {
return fmt.Errorf("failed to set JIT channels setting: %w", err)
}
}
return nil
}
@ -1561,6 +1763,19 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
return errors.New("no unlock password provided")
}
// Bark and Cashu both store wallet state on local disk, so they cannot
// run in environments without persistent volumes (e.g. Alby Cloud). Bark
// can recover spendable VTXOs from the mnemonic alone, but in-flight
// payment checkpoints and wallet metadata are local-only, so persistent
// storage is still required. The default OAuth client ID identifies a
// local / self-hosted deployment.
if !api.cfg.GetEnv().IsDefaultClientId() {
switch setupRequest.LNBackendType {
case config.BarkBackendType, config.CashuBackendType:
return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType)
}
}
err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword)
if err != nil {
return err
@ -1595,12 +1810,18 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
}
}
if setupRequest.LNDCertFile != "" {
certBytes, err := os.ReadFile(setupRequest.LNDCertFile)
// The file path is provided by the (unauthenticated) setup request, so
// only persist the content if it parses as a certificate. Storing the
// re-encoded certificate(s) guarantees nothing but the parsed structure
// reaches the database - e.g. a private key bundled in the same PEM file
// is dropped rather than persisted.
certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
if err != nil {
logger.Logger.WithError(err).Error("Failed to read lnd cert file")
return err
// Return a generic error and log the detail server-side so the
// response is not a file existence/readability oracle.
logger.Logger.WithError(err).Error("Failed to process lnd cert file")
return errors.New("invalid LND certificate file")
}
certHex := hex.EncodeToString(certBytes)
err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
@ -1608,12 +1829,17 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
}
}
if setupRequest.LNDMacaroonFile != "" {
macaroonBytes, err := os.ReadFile(setupRequest.LNDMacaroonFile)
// The file path is provided by the (unauthenticated) setup request, so
// only persist the content if it parses as a macaroon. Storing the
// re-marshalled macaroon guarantees only the parsed structure reaches
// the database.
macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
if err != nil {
logger.Logger.WithError(err).Error("Failed to read lnd macaroon file")
return err
// Return a generic error and log the detail server-side so the
// response is not a file existence/readability oracle.
logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
return errors.New("invalid LND macaroon file")
}
macaroonHex := hex.EncodeToString(macaroonBytes)
err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
@ -1653,6 +1879,15 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
}
if setupRequest.CLNLightningDir != "" {
// The directory path is provided by the (unauthenticated) setup request.
// Validate that it holds the expected CLN TLS credentials before saving,
// so the path cannot be used as an existence/readability oracle for
// arbitrary directories (the failure otherwise surfaces via startupError
// on the anonymous /api/info response).
if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
return errors.New("invalid CLN lightning directory")
}
err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
@ -1671,6 +1906,101 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
return nil
}
// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
// validates that it contains at least one parseable certificate, and returns
// the hex-encoded re-encoding of only the parsed certificate(s). Any non
// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
// never persisted. Callers must not reflect the returned error to the client.
func readAndCanonicalizeLNDCert(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read LND cert file: %w", err)
}
var canonical []byte
rest := raw
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse LND certificate: %w", err)
}
canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
})...)
}
if len(canonical) == 0 {
return "", errors.New("no valid certificate found in LND cert file")
}
return hex.EncodeToString(canonical), nil
}
// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
// validates that it is a well-formed macaroon, and returns the hex-encoded
// re-marshalling so that only the parsed structure is persisted. Callers must
// not reflect the returned error to the client.
func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
}
mac := &macaroon.Macaroon{}
if err := mac.UnmarshalBinary(raw); err != nil {
return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
}
canonical, err := mac.MarshalBinary()
if err != nil {
return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
}
return hex.EncodeToString(canonical), nil
}
// validateCLNLightningDir checks that the given directory holds the CLN TLS
// credentials that will later be loaded at connect time (ca.pem, client.pem,
// client-key.pem), for each gRPC server name the config will use. This mirrors
// the parses performed by the CLN client's loadTLSCredentials so a directory
// that passes here is one CLN can actually use. Callers must not reflect the
// returned error to the client.
func validateCLNLightningDir(lightningDir string, hold bool) error {
// "cln" reads the directory directly; other server names are joined as a
// subdirectory, matching loadTLSCredentials in lnclient/cln.
serverNames := []string{"cln"}
if hold {
serverNames = append(serverNames, "hold")
}
for _, serverName := range serverNames {
dir := lightningDir
if serverName != "cln" {
dir = filepath.Join(dir, serverName)
}
caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
if err != nil {
return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
}
if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
}
if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
}
}
return nil
}
func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
@ -1737,12 +2067,27 @@ func (api *api) SyncWallet() error {
lnClient.UpdateLastWalletSyncRequest()
return nil
}
func (api *api) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
return lnClient.ListOnchainTransactions(ctx)
transactions, err := lnClient.ListOnchainTransactions(ctx)
if err != nil {
return nil, err
}
apiTransactions := make([]OnchainTransaction, 0, len(transactions))
for _, t := range transactions {
apiTransactions = append(apiTransactions, OnchainTransaction{
AmountSat: t.AmountSat,
CreatedAt: t.CreatedAt,
State: t.State,
Type: t.Type,
NumConfirmations: t.NumConfirmations,
TxId: t.TxId,
})
}
return apiTransactions, nil
}
func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
@ -1818,7 +2163,11 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
if lnClient != nil {
nodeStatus, _ := lnClient.GetNodeStatus(ctx)
if nodeStatus == nil || !nodeStatus.IsReady {
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, nodeStatus))
var apiNodeStatus *NodeStatus
if nodeStatus != nil {
apiNodeStatus = toApiNodeStatus(nodeStatus)
}
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus))
}
channels, err := lnClient.ListChannels(ctx)

View file

@ -9,6 +9,32 @@ import (
"github.com/stretchr/testify/require"
)
func TestBuildReturnToUrl(t *testing.T) {
relayUrls := []string{"wss://relay.getalby.com/v1"}
walletPubkey := "6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7"
assert.Equal(t,
"https://example.com?pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1",
buildReturnToUrl("https://example.com", relayUrls, walletPubkey, "", false))
// existing query parameters are preserved and lud16 is added
assert.Equal(t,
"https://example.com/path?foo=bar&lud16=user%40getalby.com&pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1",
buildReturnToUrl("https://example.com/path?foo=bar", relayUrls, walletPubkey, "user@getalby.com", false))
// isolated apps do not receive a lightning address
assert.Equal(t,
"http://example.com?pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1",
buildReturnToUrl("http://example.com", relayUrls, walletPubkey, "user@getalby.com", true))
// only http and https URLs are accepted
assert.Equal(t, "", buildReturnToUrl("", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("example.com/path", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("example://app", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("javascript:void(0)", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("::invalid::", relayUrls, walletPubkey, "", false))
}
func TestCreateApp_SuperuserScopeIncorrectPassword(t *testing.T) {
cfg := mocks.NewMockConfig(t)
cfg.On("CheckUnlockPassword", "").Return(false)

View file

@ -1,9 +1,11 @@
package api
import (
"bytes"
"errors"
"fmt"
"io"
"math"
"strings"
"time"
@ -16,12 +18,48 @@ import (
"crypto/rand"
"crypto/sha256"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/utils"
"golang.org/x/crypto/pbkdf2"
)
// zipMagic is the ZIP local file header signature "PK\x03\x04" — the first
// four bytes of every ZIP file, and therefore of every archive produced by
// CreateBackup. decryptingReader uses it to detect which cipher scheme the
// backup file was created with.
var zipMagic = []byte{'P', 'K', 0x03, 0x04}
// backupCipher describes one of the cipher schemes used for backup files,
// which are laid out as salt || iv || encrypted zip archive.
type backupCipher struct {
saltSize int
deriveKey func(password string, salt []byte) ([]byte, error)
newStream func(block cipher.Block, iv []byte) cipher.Stream
}
var backupCiphers = []backupCipher{
// current scheme, used for all new backup files
{
saltSize: 32,
deriveKey: func(password string, salt []byte) ([]byte, error) {
key, _, err := config.DeriveKey(password, salt)
return key, err
},
newStream: cipher.NewCTR,
},
// legacy scheme, kept to restore backup files created by older versions
{
saltSize: 8,
deriveKey: func(password string, salt []byte) ([]byte, error) {
return pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New), nil
},
//nolint:staticcheck // OFB is required to read files created by older versions
newStream: cipher.NewOFB,
},
}
func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
logger.Logger.Info("Creating backup to migrate Alby Hub to another device")
var err error
@ -38,8 +76,9 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
return errors.New("Please disable auto-unlock before using this feature")
}
if api.db.Dialector.Name() != "sqlite" {
return errors.New("Migration with non-sqlite backend is currently not supported")
dbBackend := api.db.Dialector.Name()
if dbBackend != "sqlite" && dbBackend != "postgres" {
return fmt.Errorf("migration with %s backend is currently not supported", dbBackend)
}
workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
@ -76,6 +115,50 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
return errors.New("failed to remove oauth access token")
}
// Locate the main database file.
dbFilePath := api.cfg.GetEnv().DatabaseUri
if dbBackend == "postgres" {
// The migration file must contain a sqlite database, so copy the
// contents of the postgres database into a temporary sqlite database
// and add that to the archive instead.
dbFilePath = filepath.Join(workDir, "migration.db")
removeConvertedDb := func() {
for _, path := range []string{dbFilePath, dbFilePath + "-wal", dbFilePath + "-shm"} {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
logger.Logger.WithError(err).WithField("path", path).Error("Failed to remove converted database file")
}
}
}
// Remove stale files from a previously failed migration attempt.
removeConvertedDb()
defer removeConvertedDb()
logger.Logger.WithField("path", dbFilePath).Info("Copying postgres database to sqlite")
sqliteDb, err := db.NewDB(dbFilePath, api.cfg.GetEnv().LogDBQueries)
if err != nil {
logger.Logger.WithError(err).Error("Failed to create sqlite database for migration")
return fmt.Errorf("failed to create sqlite database for migration: %w", err)
}
err = db.MigrateDB(api.db, sqliteDb)
if err != nil {
logger.Logger.WithError(err).Error("Failed to copy database contents to sqlite")
if stopErr := db.Stop(sqliteDb); stopErr != nil {
logger.Logger.WithError(stopErr).Error("Failed to stop sqlite database")
}
return fmt.Errorf("failed to copy database contents to sqlite: %w", err)
}
// Close the sqlite database to checkpoint the WAL before archiving it.
err = db.Stop(sqliteDb)
if err != nil {
logger.Logger.WithError(err).Error("Failed to stop sqlite database")
return fmt.Errorf("failed to close sqlite database: %w", err)
}
}
// Closing the database leaves the service in an inconsistent state,
// but that should not be a problem since the app is not expected
// to be used after its data is exported.
@ -126,8 +209,6 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
return err
}
// Locate the main database file.
dbFilePath := api.cfg.GetEnv().DatabaseUri
// Add the database file to the archive.
logger.Logger.WithField("nwc.db", dbFilePath).Info("adding nwc db to zip")
err = addFileToZip(dbFilePath, "nwc.db")
@ -152,8 +233,18 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
}
}
// Finalize the archive before reporting success; the deferred close
// only covers early returns.
err = zw.Close()
if err != nil {
logger.Logger.WithError(err).Error("Failed to finalize migration archive")
return fmt.Errorf("failed to finalize migration archive: %w", err)
}
logger.Logger.Info("Successfully created backup to migrate Alby Hub to another device")
api.nodeMigrationFileCreated.Store(true)
return nil
}
@ -204,8 +295,38 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
return fmt.Errorf("failed to create zip reader: %w", err)
}
if len(zr.File) == 0 {
return errors.New("backup file contains no files")
}
restoreDir := filepath.Join(workDir, "restore")
// Extract into a staging directory and only move it to the restore
// directory once every entry has been extracted, so that a failed
// extraction cannot leave a partial restore directory behind, which
// would be applied on the next startup.
stagingDir, err := os.MkdirTemp(workDir, "albyhub-restore-")
if err != nil {
return fmt.Errorf("failed to create staging directory: %w", err)
}
defer os.RemoveAll(stagingDir)
extractZipEntry := func(zipFile *zip.File) error {
fsFilePath := filepath.Join(workDir, "restore", filepath.FromSlash(zipFile.Name))
// Entry names come from the archive and must not be trusted. Reject any
// name that is absolute or points outside the restore directory via
// ".." segments before joining it to a path.
entryName := filepath.FromSlash(zipFile.Name)
if !filepath.IsLocal(entryName) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
}
fsFilePath := filepath.Join(stagingDir, entryName)
// Confirm the cleaned path is still contained within the staging
// directory.
if fsFilePath != stagingDir && !strings.HasPrefix(fsFilePath, stagingDir+string(os.PathSeparator)) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
}
if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory for zip entry: %w", err)
@ -239,13 +360,20 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
}
logger.Logger.WithField("count", len(zr.File)).Info("Extracted files")
if err = os.RemoveAll(restoreDir); err != nil {
return fmt.Errorf("failed to remove existing restore directory: %w", err)
}
if err = os.Rename(stagingDir, restoreDir); err != nil {
return fmt.Errorf("failed to move extracted files to restore directory: %w", err)
}
go func() {
logger.Logger.Info("Backup restored. Shutting down Alby Hub...")
api.svc.Shutdown()
// ensure no -shm or -wal files exist as they will stop the restore
for _, filename := range []string{"nwc.db", "nwc.db-shm", "nwc.db-wal"} {
err = os.Remove(filepath.Join(workDir, filename))
if err != nil {
if err != nil && !errors.Is(err, os.ErrNotExist) {
logger.Logger.WithError(err).WithField("filename", filename).Error("failed to remove old nwc db file before restore")
}
}
@ -259,12 +387,17 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
}
func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
salt := make([]byte, 8)
scheme := backupCiphers[0]
salt := make([]byte, scheme.saltSize)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New)
encKey, err := scheme.deriveKey(password, salt)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
@ -285,9 +418,8 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
return nil, fmt.Errorf("failed to write IV: %w", err)
}
stream := cipher.NewOFB(block, iv)
cw := &cipher.StreamWriter{
S: stream,
S: scheme.newStream(block, iv),
W: w,
}
@ -295,27 +427,61 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
}
func decryptingReader(r io.Reader, password string) (io.Reader, error) {
salt := make([]byte, 8)
if _, err := io.ReadFull(r, salt); err != nil {
return nil, fmt.Errorf("failed to read salt: %w", err)
// Read the largest possible header (salt, IV and the first bytes of the
// archive) upfront, then trial-decrypt with each supported cipher scheme
// and pick the one that produces the ZIP signature.
maxHeaderSize := 0
minHeaderSize := math.MaxInt
for _, scheme := range backupCiphers {
headerSize := scheme.saltSize + aes.BlockSize + len(zipMagic)
maxHeaderSize = max(maxHeaderSize, headerSize)
minHeaderSize = min(minHeaderSize, headerSize)
}
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(r, iv); err != nil {
return nil, fmt.Errorf("failed to read IV: %w", err)
// Read the full header with io.ReadFull rather than io.ReadAtLeast: the
// reader may deliver short reads (e.g. a network request body), and
// stopping early could truncate the header of a scheme with a larger
// salt. A short file is only acceptable if it still covers the smallest
// scheme header.
header := make([]byte, maxHeaderSize)
n, err := io.ReadFull(r, header)
if err != nil && !(errors.Is(err, io.ErrUnexpectedEOF) && n >= minHeaderSize) {
return nil, fmt.Errorf("failed to read backup header: %w", err)
}
header = header[:n]
for _, scheme := range backupCiphers {
if len(header) < scheme.saltSize+aes.BlockSize+len(zipMagic) {
continue
}
salt := header[:scheme.saltSize]
iv := header[scheme.saltSize : scheme.saltSize+aes.BlockSize]
encrypted := header[scheme.saltSize+aes.BlockSize:]
encKey, err := scheme.deriveKey(password, salt)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
stream := scheme.newStream(block, iv)
decrypted := make([]byte, len(encrypted))
stream.XORKeyStream(decrypted, encrypted)
if !bytes.Equal(decrypted[:len(zipMagic)], zipMagic) {
continue
}
cr := &cipher.StreamReader{
S: stream,
R: r,
}
return io.MultiReader(bytes.NewReader(decrypted), cr), nil
}
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New)
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
stream := cipher.NewOFB(block, iv)
cr := &cipher.StreamReader{
S: stream,
R: r,
}
return cr, nil
return nil, errors.New("invalid unlock password or backup file")
}

261
api/backup_test.go Normal file
View file

@ -0,0 +1,261 @@
package api
import (
"archive/zip"
"bytes"
"encoding/hex"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/iotest"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
test_db "github.com/getAlby/hub/tests/db"
"github.com/getAlby/hub/tests/mocks"
)
// TestCreateBackup creates a backup from the test database (sqlite by
// default, postgres when TEST_DATABASE_URI is set) and verifies that the
// archive contains a valid sqlite database with the expected data.
func TestCreateBackup(t *testing.T) {
logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
workDir := t.TempDir()
gormDB, err := test_db.NewDB(t)
require.NoError(t, err)
defer test_db.CloseDB(gormDB)
appConfig := &config.AppConfig{
Workdir: workDir,
DatabaseUri: test_db.GetTestDatabaseURI(),
}
cfg, err := config.NewConfig(appConfig, gormDB)
require.NoError(t, err)
unlockPassword := ""
// Represent a fully set-up hub: the unlock-password canary is written during
// setup and is required for the password check to pass.
require.NoError(t, cfg.SaveUnlockPasswordCheck(unlockPassword))
app := &db.App{
Name: "test",
AppPubkey: "2b7dea2866958f17c568cf024e113db7a3baa9c253a9016889196b8d0b11c7ae",
Metadata: datatypes.JSON("{}"),
}
require.NoError(t, gormDB.Create(app).Error)
lnClient := mocks.NewMockLNClient(t)
lnClient.On("GetStorageDir").Return("", nil)
lnClient.On("ResetRouter", "ALL").Return(nil)
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(lnClient)
svc.On("StopApp").Return()
albyOAuthSvc := mocks.NewMockAlbyOAuthService(t)
albyOAuthSvc.On("RemoveOAuthAccessToken").Return(nil)
theAPI := &api{
db: gormDB,
cfg: cfg,
svc: svc,
albyOAuthSvc: albyOAuthSvc,
}
var buf bytes.Buffer
err = theAPI.CreateBackup(unlockPassword, &buf)
require.NoError(t, err)
// The temporary database created when converting from postgres must
// not be left behind in the working directory.
entries, err := os.ReadDir(workDir)
require.NoError(t, err)
require.Empty(t, entries)
cr, err := decryptingReader(&buf, unlockPassword)
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
restoredPath := filepath.Join(workDir, "restored.db")
require.NoError(t, os.WriteFile(restoredPath, dbContents, 0600))
restoredDB, err := db.NewDB(restoredPath, false)
require.NoError(t, err)
defer func() {
require.NoError(t, db.Stop(restoredDB))
}()
var restoredApp db.App
require.NoError(t, restoredDB.First(&restoredApp).Error)
require.Equal(t, app.Name, restoredApp.Name)
require.Equal(t, app.AppPubkey, restoredApp.AppPubkey)
}
// TestRestoreBackupRejectsPathTraversal verifies that a backup archive
// containing an entry whose name points outside the restore directory is
// rejected and that no file is written outside it.
func TestRestoreBackupRejectsPathTraversal(t *testing.T) {
logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
gormDB, err := test_db.NewDB(t)
require.NoError(t, err)
defer test_db.CloseDB(gormDB)
if gormDB.Dialector.Name() != "sqlite" {
t.Skip("restore is only supported on sqlite")
}
workDir := t.TempDir()
appConfig := &config.AppConfig{
Workdir: workDir,
DatabaseUri: test_db.GetTestDatabaseURI(),
}
cfg, err := config.NewConfig(appConfig, gormDB)
require.NoError(t, err)
theAPI := &api{
db: gormDB,
cfg: cfg,
}
unlockPassword := ""
// The restore directory is <workDir>/restore, so a "../" entry targets a
// file directly in the working directory, one level above it.
const escapeEntryName = "../pwned.txt"
escapeTarget := filepath.Join(workDir, "pwned.txt")
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, unlockPassword)
require.NoError(t, err)
zw := zip.NewWriter(cw)
// A valid entry before the malicious one, to verify that a partially
// extracted archive is not left behind when a later entry fails.
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
entryWriter, err = zw.Create(escapeEntryName)
require.NoError(t, err)
_, err = entryWriter.Write([]byte("pwned"))
require.NoError(t, err)
require.NoError(t, zw.Close())
err = theAPI.RestoreBackup(unlockPassword, &buf)
require.ErrorContains(t, err, "refusing to extract zip entry outside restore directory")
_, statErr := os.Stat(escapeTarget)
require.True(t, os.IsNotExist(statErr), "traversal entry must not be written outside the restore directory")
// The failed restore must not leave a restore directory (which would be
// applied on the next startup) or any staging leftovers.
_, statErr = os.Stat(filepath.Join(workDir, "restore"))
require.True(t, os.IsNotExist(statErr), "failed restore must not leave a restore directory")
entries, err := os.ReadDir(workDir)
require.NoError(t, err)
for _, entry := range entries {
require.False(t, strings.HasPrefix(entry.Name(), "albyhub-restore-"), "failed restore must not leave a staging directory")
}
}
// legacyBackupFixture is a backup file created with the encryption scheme
// used by older versions (PBKDF2 key derivation), encrypted with the
// password "test-unlock-password". Its archive contains a single "nwc.db"
// entry with the contents "legacy backup contents".
const legacyBackupFixture = "0102030405060708101112131415161718191a1b1c1d1e1f8eca79631915f679a00cdd95d3f20d8d169eb9aa5d52642ca13b93886c3c7d7ba4b759462bc9dd8deccf638edcc9b5b9fda3d23dcd904cf6e99bc57ac59c4df6be5aa676542b7cbc9998029420c0ae5a6986c735150ababde5b382560acaebd5894aa4420924f1ced63fde570adc60c43b32e9e14a0ef60c379da5cac1be0000845992ea072ead036e336c7b859e8d018c4ef61667e3f520fe01"
// TestDecryptingReaderLegacyBackup verifies that backup files created by
// older versions can still be decrypted.
func TestDecryptingReaderLegacyBackup(t *testing.T) {
encrypted, err := hex.DecodeString(legacyBackupFixture)
require.NoError(t, err)
cr, err := decryptingReader(bytes.NewReader(encrypted), "test-unlock-password")
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
require.Equal(t, "legacy backup contents", string(dbContents))
}
// TestDecryptingReaderFragmentedReader verifies that a backup file is
// decrypted correctly even when the reader delivers one byte at a time,
// which would truncate the header if it were not read in full.
func TestDecryptingReaderFragmentedReader(t *testing.T) {
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, "test-unlock-password")
require.NoError(t, err)
zw := zip.NewWriter(cw)
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
require.NoError(t, zw.Close())
cr, err := decryptingReader(iotest.OneByteReader(bytes.NewReader(buf.Bytes())), "test-unlock-password")
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
require.Equal(t, "backup contents", string(dbContents))
}
// TestDecryptingReaderWrongPassword verifies that decryption fails upfront
// when the password does not match the backup file.
func TestDecryptingReaderWrongPassword(t *testing.T) {
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, "test-unlock-password")
require.NoError(t, err)
zw := zip.NewWriter(cw)
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
require.NoError(t, zw.Close())
_, err = decryptingReader(bytes.NewReader(buf.Bytes()), "wrong-password")
require.Error(t, err)
}

View file

@ -8,7 +8,6 @@ import (
"github.com/getAlby/hub/alby"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/swaps"
)
@ -23,14 +22,15 @@ type API interface {
DeleteLightningAddress(ctx context.Context, appId uint) error
ListChannels(ctx context.Context) ([]Channel, error)
GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error)
GetStories(ctx context.Context) ([]alby.Story, error)
GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error)
ResetRouter(key string) error
ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error
SetAutoUnlockPassword(unlockPassword string) error
Stop() error
GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error)
GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error)
ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error)
GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error)
GetNodeStatus(ctx context.Context) (*NodeStatus, error)
ListPeers(ctx context.Context) ([]PeerDetails, error)
ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error
DisconnectPeer(ctx context.Context, peerId string) error
OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error)
@ -43,10 +43,10 @@ type API interface {
SignMessage(ctx context.Context, message string) (*SignMessageResponse, error)
RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
GetBalances(ctx context.Context) (*BalancesResponse, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error)
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
@ -64,8 +64,6 @@ type API interface {
MigrateNodeStorage(ctx context.Context, to string) error
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
Health(ctx context.Context) (*HealthResponse, error)
SetCurrency(currency string) error
SetBitcoinDisplayFormat(format string) error
UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error
LookupSwap(swapId string) (*LookupSwapResponse, error)
ListSwaps() (*ListSwapsResponse, error)
@ -306,38 +304,47 @@ type InfoResponseRelay struct {
}
type InfoResponse struct {
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
OAuthRedirect bool `json:"oauthRedirect"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
AlbyAuthUrl string `json:"albyAuthUrl"`
NextBackupReminder string `json:"nextBackupReminder"`
AlbyUserIdentifier string `json:"albyUserIdentifier"`
AlbyAccountConnected bool `json:"albyAccountConnected"`
Version string `json:"version"`
Network string `json:"network"`
EnableAdvancedSetup bool `json:"enableAdvancedSetup"`
LdkVssEnabled bool `json:"ldkVssEnabled"`
VssSupported bool `json:"vssSupported"`
StartupState string `json:"startupState"`
StartupError string `json:"startupError"`
StartupErrorTime time.Time `json:"startupErrorTime"`
AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"`
AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"`
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
Relays []InfoResponseRelay `json:"relays"`
NodeAlias string `json:"nodeAlias"`
MempoolUrl string `json:"mempoolUrl"`
ChainDataSourceType string `json:"chainDataSourceType,omitempty"`
ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"`
HideUpdateBanner bool `json:"hideUpdateBanner"`
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
OAuthRedirect bool `json:"oauthRedirect"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
AlbyAuthUrl string `json:"albyAuthUrl"`
NextBackupReminder string `json:"nextBackupReminder"`
AlbyUserIdentifier string `json:"albyUserIdentifier"`
AlbyAccountConnected bool `json:"albyAccountConnected"`
Version string `json:"version"`
Network string `json:"network"`
EnableAdvancedSetup bool `json:"enableAdvancedSetup"`
LdkVssEnabled bool `json:"ldkVssEnabled"`
LdkVssUrl string `json:"ldkVssUrl"`
VssSupported bool `json:"vssSupported"`
DatabaseType string `json:"databaseType"`
StartupState string `json:"startupState"`
StartupError string `json:"startupError"`
StartupErrorTime time.Time `json:"startupErrorTime"`
AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"`
AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"`
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
Relays []InfoResponseRelay `json:"relays"`
NodeAlias string `json:"nodeAlias"`
MempoolUrl string `json:"mempoolUrl"`
ChainDataSourceType string `json:"chainDataSourceType,omitempty"`
ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"`
JitChannelsLiquiditySource string `json:"jitChannelsLiquiditySource,omitempty"`
JitChannelsMinPaymentSizeMsat *uint64 `json:"jitChannelsMinPaymentSizeMsat,omitempty"`
JitChannelsMaxPaymentSizeMsat *uint64 `json:"jitChannelsMaxPaymentSizeMsat,omitempty"`
JitChannelsEnabled bool `json:"jitChannelsEnabled"`
HideUpdateBanner bool `json:"hideUpdateBanner"`
SupportsBolt12 bool `json:"supportsBolt12"`
NodeMigrationFileCreated bool `json:"nodeMigrationFileCreated"`
}
type UpdateSettingsRequest struct {
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
JitChannelsEnabled *bool `json:"jitChannelsEnabled"`
}
type SetNodeAliasRequest struct {
@ -360,11 +367,68 @@ type AutoUnlockRequest struct {
UnlockPassword string `json:"unlockPassword"`
}
type ConnectPeerRequest = lnclient.ConnectPeerRequest
type OpenChannelRequest = lnclient.OpenChannelRequest
type OpenChannelResponse = lnclient.OpenChannelResponse
type CloseChannelResponse = lnclient.CloseChannelResponse
type UpdateChannelRequest = lnclient.UpdateChannelRequest
type ConnectPeerRequest struct {
Pubkey string `json:"pubkey"`
Address string `json:"address"`
Port uint16 `json:"port"`
}
type OpenChannelRequest struct {
Pubkey string `json:"pubkey"`
AmountSats int64 `json:"amountSats"`
Public bool `json:"public"`
}
type OpenChannelResponse struct {
FundingTxId string `json:"fundingTxId"`
}
type CloseChannelResponse struct {
}
type UpdateChannelRequest struct {
ChannelId string `json:"channelId"`
NodeId string `json:"nodeId"`
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"`
ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"`
MaxDustHtlcExposureFromFeeRateMultiplier uint64 `json:"maxDustHtlcExposureFromFeeRateMultiplier"`
}
type NodeConnectionInfo struct {
Pubkey string `json:"pubkey"`
Address string `json:"address"`
Port int `json:"port"`
}
type NodeStatus struct {
IsReady bool `json:"isReady"`
InternalNodeStatus interface{} `json:"internalNodeStatus"`
}
type PeerDetails struct {
NodeId string `json:"nodeId"`
Address string `json:"address"`
IsPersisted bool `json:"isPersisted"`
IsConnected bool `json:"isConnected"`
}
type OnchainTransaction struct {
AmountSat uint64 `json:"amountSat"`
CreatedAt uint64 `json:"createdAt"`
State string `json:"state"`
Type string `json:"type"`
NumConfirmations uint32 `json:"numConfirmations"`
TxId string `json:"txId"`
}
type PendingBalanceDetails struct {
ChannelId string `json:"channelId"`
NodeId string `json:"nodeId"`
Amount uint64 `json:"amount"` // deprecated
AmountSat uint64 `json:"amountSat"`
FundingTxId string `json:"fundingTxId"`
FundingTxVout uint32 `json:"fundingTxVout"`
}
type RebalanceChannelRequest struct {
ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"`
@ -388,8 +452,45 @@ type RedeemOnchainFundsResponse struct {
TxId string `json:"txId"`
}
type OnchainBalanceResponse = lnclient.OnchainBalanceResponse
type BalancesResponse = lnclient.BalancesResponse
type OnchainBalanceResponse struct {
Spendable int64 `json:"spendable"` // deprecated
SpendableSat int64 `json:"spendableSat"`
Total int64 `json:"total"` // deprecated
TotalSat int64 `json:"totalSat"`
Reserved int64 `json:"reserved"` // deprecated
ReservedSat int64 `json:"reservedSat"`
PendingBalancesFromChannelClosures uint64 `json:"pendingBalancesFromChannelClosures"` // deprecated
PendingBalancesFromChannelClosuresSat uint64 `json:"pendingBalancesFromChannelClosuresSat"`
PendingBalancesDetails []PendingBalanceDetails `json:"pendingBalancesDetails"`
PendingSweepBalancesDetails []PendingBalanceDetails `json:"pendingSweepBalancesDetails"`
InternalBalances interface{} `json:"internalBalances"`
}
type LightningBalanceResponse struct {
TotalSpendable int64 `json:"totalSpendable"` // deprecated
TotalSpendableSat int64 `json:"totalSpendableSat"`
TotalSpendableMsat int64 `json:"totalSpendableMsat"`
TotalReceivable int64 `json:"totalReceivable"` // deprecated
TotalReceivableSat int64 `json:"totalReceivableSat"`
TotalReceivableMsat int64 `json:"totalReceivableMsat"`
NextMaxSpendable int64 `json:"nextMaxSpendable"` // deprecated
NextMaxSpendableSat int64 `json:"nextMaxSpendableSat"`
NextMaxSpendableMsat int64 `json:"nextMaxSpendableMsat"`
NextMaxReceivable int64 `json:"nextMaxReceivable"` // deprecated
NextMaxReceivableSat int64 `json:"nextMaxReceivableSat"`
NextMaxReceivableMsat int64 `json:"nextMaxReceivableMsat"`
NextMaxSpendableMPP int64 `json:"nextMaxSpendableMPP"` // deprecated
NextMaxSpendableMPPSat int64 `json:"nextMaxSpendableMPPSat"`
NextMaxSpendableMPPMsat int64 `json:"nextMaxSpendableMPPMsat"`
NextMaxReceivableMPP int64 `json:"nextMaxReceivableMPP"` // deprecated
NextMaxReceivableMPPSat int64 `json:"nextMaxReceivableMPPSat"`
NextMaxReceivableMPPMsat int64 `json:"nextMaxReceivableMPPMsat"`
}
type BalancesResponse struct {
Onchain OnchainBalanceResponse `json:"onchain"`
Lightning LightningBalanceResponse `json:"lightning"`
}
type SendPaymentResponse = Transaction
type MakeInvoiceResponse = Transaction
@ -399,6 +500,13 @@ type SetTransactionUserLabelsRequest struct {
Labels map[string]string `json:"labels"`
}
type ListTransactionsFilters struct {
Type *string
MinAmountMsat *uint64
HideFailed bool
SearchTerm string
}
type ListTransactionsResponse struct {
TotalCount uint64 `json:"totalCount"`
Transactions []Transaction `json:"transactions"`
@ -488,6 +596,7 @@ type MakeInvoiceRequest struct {
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
Description string `json:"description"`
ToAppID *uint `json:"toAppId"`
}
type ResetRouterRequest struct {
@ -502,7 +611,7 @@ type BasicRestoreWailsRequest struct {
UnlockPassword string `json:"unlockPassword"`
}
type NetworkGraphResponse = lnclient.NetworkGraphResponse
type NetworkGraphResponse = interface{}
type LSPOrderRequest struct {
Amount *uint64 `json:"amount"` // deprecated

View file

@ -122,7 +122,7 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R
return nil, err
}
if paymentRequest.MSatoshi > int64(float64(amountSat)*float64(1000)*float64(1.003)+1 /*0.3% fees*/) {
if paymentRequest.MSatoshi > int64(float64(amountSat)*float64(1000)*float64(1.005)+1 /*0.5% fees*/) {
return nil, errors.New("rebalance payment is more expensive than expected")
}

161
api/setup_test.go Normal file
View file

@ -0,0 +1,161 @@
package api
import (
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"gopkg.in/macaroon.v2"
)
// generateTestCert returns a self-signed certificate PEM block and its
// matching EC private key PEM block.
func generateTestCert(t *testing.T) (certPEM []byte, keyPEM []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Unix(0, 0),
NotAfter: time.Unix(1<<31, 0),
}
der, err := x509.CreateCertificate(crand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, err)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM
}
func TestReadAndCanonicalizeLNDCert(t *testing.T) {
certPEM, keyPEM := generateTestCert(t)
dir := t.TempDir()
t.Run("valid certificate", func(t *testing.T) {
path := filepath.Join(dir, "tls.cert")
require.NoError(t, os.WriteFile(path, certPEM, 0600))
got, err := readAndCanonicalizeLNDCert(path)
require.NoError(t, err)
raw, err := hex.DecodeString(got)
require.NoError(t, err)
require.True(t, x509.NewCertPool().AppendCertsFromPEM(raw))
})
t.Run("bundled private key is stripped", func(t *testing.T) {
path := filepath.Join(dir, "bundle.pem")
require.NoError(t, os.WriteFile(path, append(append([]byte{}, certPEM...), keyPEM...), 0600))
got, err := readAndCanonicalizeLNDCert(path)
require.NoError(t, err)
raw, err := hex.DecodeString(got)
require.NoError(t, err)
// Only the CERTIFICATE block must survive - the private key must not
// be persisted.
require.NotContains(t, string(raw), "PRIVATE KEY")
require.Contains(t, string(raw), "CERTIFICATE")
})
t.Run("arbitrary non-cert file is rejected", func(t *testing.T) {
path := filepath.Join(dir, "secret.txt")
require.NoError(t, os.WriteFile(path, []byte("root:x:0:0:root:/root:/bin/bash\n"), 0600))
_, err := readAndCanonicalizeLNDCert(path)
require.Error(t, err)
})
t.Run("missing file is rejected", func(t *testing.T) {
_, err := readAndCanonicalizeLNDCert(filepath.Join(dir, "does-not-exist"))
require.Error(t, err)
})
}
func TestReadAndCanonicalizeLNDMacaroon(t *testing.T) {
dir := t.TempDir()
t.Run("valid macaroon", func(t *testing.T) {
mac, err := macaroon.New([]byte("root-key"), []byte("id"), "location", macaroon.LatestVersion)
require.NoError(t, err)
raw, err := mac.MarshalBinary()
require.NoError(t, err)
path := filepath.Join(dir, "admin.macaroon")
require.NoError(t, os.WriteFile(path, raw, 0600))
got, err := readAndCanonicalizeLNDMacaroon(path)
require.NoError(t, err)
gotRaw, err := hex.DecodeString(got)
require.NoError(t, err)
roundTrip := &macaroon.Macaroon{}
require.NoError(t, roundTrip.UnmarshalBinary(gotRaw))
})
t.Run("arbitrary non-macaroon file is rejected", func(t *testing.T) {
path := filepath.Join(dir, "id_rsa")
require.NoError(t, os.WriteFile(path, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n"), 0600))
_, err := readAndCanonicalizeLNDMacaroon(path)
require.Error(t, err)
})
t.Run("missing file is rejected", func(t *testing.T) {
_, err := readAndCanonicalizeLNDMacaroon(filepath.Join(dir, "does-not-exist"))
require.Error(t, err)
})
}
func TestValidateCLNLightningDir(t *testing.T) {
certPEM, keyPEM := generateTestCert(t)
writeCLNDir := func(t *testing.T, dir string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), certPEM, 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "client.pem"), certPEM, 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "client-key.pem"), keyPEM, 0600))
}
t.Run("valid directory", func(t *testing.T) {
dir := t.TempDir()
writeCLNDir(t, dir)
require.NoError(t, validateCLNLightningDir(dir, false))
})
t.Run("valid directory with hold subdirectory", func(t *testing.T) {
dir := t.TempDir()
writeCLNDir(t, dir)
holdDir := filepath.Join(dir, "hold")
require.NoError(t, os.Mkdir(holdDir, 0700))
writeCLNDir(t, holdDir)
require.NoError(t, validateCLNLightningDir(dir, true))
})
t.Run("hold requested but subdirectory missing", func(t *testing.T) {
dir := t.TempDir()
writeCLNDir(t, dir)
require.Error(t, validateCLNLightningDir(dir, true))
})
t.Run("arbitrary directory is rejected", func(t *testing.T) {
require.Error(t, validateCLNLightningDir(t.TempDir(), false))
})
}

View file

@ -4,20 +4,29 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/transactions"
"github.com/sirupsen/logrus"
)
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
if toAppId != nil && api.appsSvc.GetAppById(*toAppId) == nil {
return nil, errors.New("app does not exist")
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
if err != nil {
return nil, err
}
@ -40,7 +49,47 @@ func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels ma
return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels)
}
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) {
// ParseListTransactionsFilters parses transaction filter query parameters
// shared by the HTTP and Wails transports. Invalid values return an error.
func ParseListTransactionsFilters(query url.Values) (ListTransactionsFilters, error) {
filters := ListTransactionsFilters{}
if transactionType := query.Get("type"); transactionType != "" {
if transactionType != constants.TRANSACTION_TYPE_INCOMING && transactionType != constants.TRANSACTION_TYPE_OUTGOING {
return filters, fmt.Errorf("invalid type: %s", transactionType)
}
filters.Type = &transactionType
}
if minAmountSatParam := query.Get("minAmountSat"); minAmountSatParam != "" {
minAmountSat, err := strconv.ParseUint(minAmountSatParam, 10, 64)
if err != nil || minAmountSat == 0 {
return filters, fmt.Errorf("invalid minAmountSat: %s", minAmountSatParam)
}
const msatPerSat = uint64(1000)
if minAmountSat > ^uint64(0)/msatPerSat {
return filters, fmt.Errorf("minAmountSat is too large")
}
minAmountMsat := minAmountSat * msatPerSat
filters.MinAmountMsat = &minAmountMsat
}
if hideFailedParam := query.Get("hideFailed"); hideFailedParam != "" {
hideFailed, err := strconv.ParseBool(hideFailedParam)
if err != nil {
return filters, fmt.Errorf("invalid hideFailed: %s", hideFailedParam)
}
filters.HideFailed = hideFailed
}
filters.SearchTerm = strings.TrimSpace(query.Get("search"))
return filters, nil
}
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
@ -51,13 +100,18 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64,
forceFilterByAppId = true
}
transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, lnClient, appId, forceFilterByAppId)
dbTransactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, lnClient, appId, forceFilterByAppId, &transactions.ListTransactionsFilters{
Type: filters.Type,
MinAmountMsat: filters.MinAmountMsat,
HideFailed: filters.HideFailed,
SearchTerm: filters.SearchTerm,
})
if err != nil {
return nil, err
}
apiTransactions := []Transaction{}
for _, transaction := range transactions {
for _, transaction := range dbTransactions {
apiTransactions = append(apiTransactions, *toApiTransaction(&transaction))
}

98
api/transactions_test.go Normal file
View file

@ -0,0 +1,98 @@
package api
import (
"context"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/getAlby/hub/tests"
"github.com/getAlby/hub/tests/mocks"
"github.com/getAlby/hub/transactions"
)
func TestCreateInvoice_ToApp(t *testing.T) {
ctx := context.TODO()
testSvc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer testSvc.Remove()
app, _, err := tests.CreateApp(testSvc)
require.NoError(t, err)
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(testSvc.LNClient)
svc.On("GetTransactionsService").Return(transactions.NewTransactionsService(testSvc.DB, testSvc.EventPublisher))
theAPI := &api{
appsSvc: testSvc.AppsService,
svc: svc,
}
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &app.ID)
require.NoError(t, err)
require.NotNil(t, transaction.AppId)
assert.Equal(t, app.ID, *transaction.AppId)
}
func TestCreateInvoice_ToAppNotFound(t *testing.T) {
ctx := context.TODO()
testSvc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer testSvc.Remove()
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(testSvc.LNClient)
theAPI := &api{
appsSvc: testSvc.AppsService,
svc: svc,
}
missingAppId := uint(999)
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &missingAppId)
assert.Nil(t, transaction)
require.Error(t, err)
assert.Equal(t, "app does not exist", err.Error())
}
func TestParseListTransactionsFilters(t *testing.T) {
minAmountMsat := uint64(1000_000)
outgoing := "outgoing"
filters, err := ParseListTransactionsFilters(url.Values{
"type": {"outgoing"},
"minAmountSat": {"1000"},
"hideFailed": {"true"},
"search": {" coffee "},
})
require.NoError(t, err)
assert.Equal(t, ListTransactionsFilters{
Type: &outgoing,
MinAmountMsat: &minAmountMsat,
HideFailed: true,
SearchTerm: "coffee",
}, filters)
filters, err = ParseListTransactionsFilters(url.Values{})
require.NoError(t, err)
assert.Equal(t, ListTransactionsFilters{}, filters)
for _, invalidQuery := range []url.Values{
{"type": {"sideways"}},
{"minAmountSat": {"abc"}},
{"minAmountSat": {"-1"}},
{"minAmountSat": {"0"}},
{"minAmountSat": {"18446744073709551615"}},
{"hideFailed": {"maybe"}},
} {
_, err = ParseListTransactionsFilters(invalidQuery)
assert.Error(t, err, "query: %v", invalidQuery)
}
}

View file

@ -58,9 +58,11 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
backendType, _ := svc.cfg.Get("LNBackendType", "")
if backendType != config.LDKBackendType &&
backendType != config.LNDBackendType &&
backendType != config.PhoenixBackendType {
backendType != config.PhoenixBackendType &&
backendType != config.BarkBackendType &&
backendType != config.CLNBackendType {
return nil, "", fmt.Errorf(
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
"sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN")
}
}
@ -184,11 +186,17 @@ func (svc *appsService) DeleteApp(app *db.App) error {
if err != nil {
return err
}
walletPubkey := ""
if app.WalletPubkey != nil {
// only exists for non-legacy apps
walletPubkey = *app.WalletPubkey
}
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_app_deleted",
Properties: map[string]interface{}{
"name": app.Name,
"id": app.ID,
"name": app.Name,
"id": app.ID,
"walletPubkey": walletPubkey,
},
})
return nil

View file

@ -54,5 +54,5 @@ func TestHandleCreateApp_IsolatedUnsupportedBackendType(t *testing.T) {
assert.Nil(t, app)
assert.Equal(t, "", secretKey)
require.Error(t, err)
assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK or LND", err.Error())
assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN", err.Error())
}

View file

@ -2,9 +2,7 @@ package main
import (
"flag"
"fmt"
"os"
"slices"
"strconv"
"github.com/sirupsen/logrus"
@ -14,18 +12,6 @@ import (
"github.com/getAlby/hub/logger"
)
var expectedTables = []string{
"apps",
"app_permissions",
"request_events",
"response_events",
"transactions",
"swaps",
"user_configs",
"migrations",
"forwards",
}
func main() {
var fromDSN, toDSN string
@ -64,54 +50,29 @@ func main() {
}
defer stopDB(toDB)
// Migrations are applied to both the source and the target DB, so
// schemas should be equal at this point.
err = checkSchema(fromDB)
if err != nil {
logger.Logger.WithError(err).Error("database schema check failed; the migration tool may be outdated")
os.Exit(1)
}
// Check if VSS is enabled in the source database
var vssConfig db.UserConfig
result := fromDB.Where("key = ?", "LdkVssEnabled").First(&vssConfig)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
logger.Logger.Error("LdkVssEnabled config not found in source DB. Migration will not proceed.")
} else {
logger.Logger.WithError(result.Error).Error("failed to query LdkVssEnabled config from source DB")
// When migrating to Postgres (e.g. a cloud deployment) the node data must
// be stored in VSS, since only the database is migrated by this tool.
if toDB.Dialector.Name() == "postgres" {
var vssConfig db.UserConfig
result := fromDB.Where("key = ?", "LdkVssEnabled").First(&vssConfig)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
logger.Logger.Error("LdkVssEnabled config not found in source DB. Migration will not proceed.")
} else {
logger.Logger.WithError(result.Error).Error("failed to query LdkVssEnabled config from source DB")
}
os.Exit(1)
}
os.Exit(1)
}
if vssConfig.Value != "true" {
logger.Logger.Error("VSS is not enabled in the source DB (LdkVssEnabled is not 'true'). Migration will not proceed.")
os.Exit(1)
}
logger.Logger.Info("LdkVssEnabled check passed.")
// NOTE: we assume that excess request events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned request events.")
err = fromDB.Exec("DELETE FROM request_events WHERE app_id NOT IN (SELECT id FROM apps);").Error
if err != nil {
logger.Logger.WithError(err).Error("failed to delete orphaned request events")
os.Exit(1)
}
// NOTE: we assume that excess response events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned response events.")
err = fromDB.Exec("DELETE FROM response_events WHERE request_id NOT IN (SELECT id FROM request_events);").Error
if err != nil {
logger.Logger.WithError(err).Error("failed to delete orphaned response events")
os.Exit(1)
if vssConfig.Value != "true" {
logger.Logger.Error("VSS is not enabled in the source DB (LdkVssEnabled is not 'true'). Migration will not proceed.")
os.Exit(1)
}
logger.Logger.Info("LdkVssEnabled check passed.")
}
logger.Logger.Info("migrating...")
err = migrateDB(fromDB, toDB)
err = db.MigrateDB(fromDB, toDB)
if err != nil {
logger.Logger.WithError(err).Error("failed to migrate database")
os.Exit(1)
@ -119,175 +80,3 @@ func main() {
logger.Logger.Info("migration complete")
}
func migrateDB(from, to *gorm.DB) error {
tx := to.Begin()
defer tx.Rollback()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
// Table migration order matters: referenced tables must be migrated
// before referencing tables.
logger.Logger.Info("migrating apps...")
if err := migrateTable[db.App](from, tx); err != nil {
return fmt.Errorf("failed to migrate apps: %w", err)
}
logger.Logger.Info("migrating app_permissions...")
if err := migrateTable[db.AppPermission](from, tx); err != nil {
return fmt.Errorf("failed to migrate app_permissions: %w", err)
}
logger.Logger.Info("migrating request_events...")
if err := migrateTable[db.RequestEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate request_events: %w", err)
}
logger.Logger.Info("migrating response_events...")
if err := migrateTable[db.ResponseEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate response_events: %w", err)
}
logger.Logger.Info("migrating transactions...")
if err := migrateTable[db.Transaction](from, tx); err != nil {
return fmt.Errorf("failed to migrate transactions: %w", err)
}
logger.Logger.Info("migrating user_configs...")
if err := migrateTable[db.UserConfig](from, tx); err != nil {
return fmt.Errorf("failed to migrate user_configs: %w", err)
}
if to.Dialector.Name() == "postgres" {
logger.Logger.Info("resetting sequences...")
if err := resetSequences(tx); err != nil {
return fmt.Errorf("failed to reset sequences: %w", err)
}
}
tx.Commit()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func migrateTable[T any](from, to *gorm.DB) error {
var data []T
if err := from.Find(&data).Error; err != nil {
return fmt.Errorf("failed to fetch data: %w", err)
}
if len(data) == 0 {
return nil
}
// to avoid "failed to migrate transactions: failed to insert data: extended protocol limited to 65535 parameters"
// see https://stackoverflow.com/questions/77372430/extended-protocol-limited-to-65535-parameters-golang-gorm
// max statements is 65535
// but it's the number of records * columns
// to be safe, using a lower value of 1000.
// this will fail if any table has more than 65 columns, which I doubt we will have
max := 1000
for i := 0; i < len(data); i += max {
j := min(i+max, len(data))
if err := to.Create(data[i:j]).Error; err != nil {
return fmt.Errorf("failed to insert data: %w", err)
}
}
return nil
}
func checkSchema(db *gorm.DB) error {
tables, err := listTables(db)
if err != nil {
return fmt.Errorf("failed to list database tables: %w", err)
}
for _, table := range expectedTables {
if !slices.Contains(tables, table) {
return fmt.Errorf("table missing from the database: %q", table)
}
}
for _, table := range tables {
if !slices.Contains(expectedTables, table) {
return fmt.Errorf("unexpected table found in the database: %q", table)
}
}
return nil
}
func listTables(db *gorm.DB) ([]string, error) {
var query string
switch db.Dialector.Name() {
case "sqlite":
query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"
case "postgres":
query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';"
default:
return nil, fmt.Errorf("unsupported database: %q", db.Dialector.Name())
}
rows, err := db.Raw(query).Rows()
if err != nil {
return nil, fmt.Errorf("failed to query table names: %w", err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Logger.WithError(err).Error("failed to close rows")
}
}()
var tables []string
for rows.Next() {
var table string
if err := rows.Scan(&table); err != nil {
return nil, fmt.Errorf("failed to scan table name: %w", err)
}
tables = append(tables, table)
}
return tables, nil
}
func resetSequences(db *gorm.DB) error {
type resetReq struct {
table string
seq string
}
resetReqs := []resetReq{
{"apps", "apps_2_id_seq"},
{"app_permissions", "app_permissions_2_id_seq"},
{"request_events", "request_events_id_seq"},
{"response_events", "response_events_id_seq"},
{"transactions", "transactions_id_seq"},
{"user_configs", "user_configs_id_seq"},
}
for _, req := range resetReqs {
if err := resetPostgresSequence(db, req.table, req.seq); err != nil {
return fmt.Errorf("failed to reset sequence %q for %q: %w", req.seq, req.table, err)
}
}
return nil
}
func resetPostgresSequence(db *gorm.DB, table string, seq string) error {
query := fmt.Sprintf("SELECT setval('%s', (SELECT MAX(id) FROM %s));", seq, table)
if err := db.Exec(query).Error; err != nil {
return fmt.Errorf("failed to execute setval(): %w", err)
}
return nil
}

View file

@ -30,40 +30,6 @@ func (e *testEnvironment) cleanup(t *testing.T) {
require.NoError(t, err)
}
func TestSchemaCheck(t *testing.T) {
type testCase struct {
name string
uri string
}
tc := []testCase{
{
name: "schema check sqlite",
uri: getTestSqliteURI(0),
},
}
if pgUri := getTestPostgresURI(); pgUri != "" {
tc = append(tc, testCase{
name: "schema check postgres",
uri: pgUri,
})
}
logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
dbConn, err := test_db.NewDBWithURI(t, tt.uri)
require.NoError(t, err)
defer db.Stop(dbConn)
err = checkSchema(dbConn)
require.NoError(t, err)
})
}
}
func TestMigrate(t *testing.T) {
type testCase struct {
name string
@ -104,8 +70,17 @@ func TestMigrate(t *testing.T) {
require.NoError(t, err)
defer env.cleanup(t)
err = migrateDB(env.source, env.dest)
err = db.MigrateDB(env.source, env.dest)
require.NoError(t, err)
requireCount[db.App](t, env.dest, 2)
requireCount[db.AppPermission](t, env.dest, 2)
requireCount[db.RequestEvent](t, env.dest, 1)
requireCount[db.ResponseEvent](t, env.dest, 1)
requireCount[db.Transaction](t, env.dest, 1)
requireCount[db.Swap](t, env.dest, 1)
requireCount[db.Forward](t, env.dest, 1)
requireCount[db.UserConfig](t, env.dest, 1)
})
}
}
@ -200,6 +175,83 @@ func insertMockData(t *testing.T, tx *gorm.DB) {
UpdatedAt: baseTime,
}
create(t, tx, app2Perm)
requestEvent1 := &db.RequestEvent{
AppId: &app1.ID,
NostrId: "a35a1ca6d1a06e08a509f2c8fe3edb2ba10811d030e2f6f3239e9f21203ac954",
ContentData: "{}",
Method: "pay_invoice",
State: "executed",
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, requestEvent1)
responseEvent1 := &db.ResponseEvent{
NostrId: "e30d55d0e4f0d5391a1a1379f1d8b7d38ad02b3554b06ca993aa8790a3153f61",
RequestId: requestEvent1.ID,
State: "confirmed",
RepliedAt: baseTime,
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, responseEvent1)
transaction1 := &db.Transaction{
AppId: &app1.ID,
RequestEventId: &requestEvent1.ID,
Type: "outgoing",
State: "settled",
AmountMsat: 21000,
FeeMsat: 1000,
PaymentRequest: "lnbc210n1invoice",
PaymentHash: "13d9764a54269fa4d5f4e7c410f4ffdbc839bbeaa2fcbb96343ca502f0c86e34",
Description: "test transaction",
Preimage: ptr("2c1ee1b464b1a1a147debe0ac0c8ce4b615f9bfa64d12a25c1c4d10ea45a5b02"),
CreatedAt: baseTime,
UpdatedAt: baseTime,
SettledAt: &baseTime,
Metadata: datatypes.JSON("{}"),
Boostagram: datatypes.JSON("{}"),
}
create(t, tx, transaction1)
swap1 := &db.Swap{
SwapId: "swap1",
Type: "out",
State: "success",
Invoice: "lnbc210n1swapinvoice",
SendAmountSat: 21000,
ReceiveAmountSat: 20000,
Preimage: "35a3f1a7a06a41b9ba3a1b1a8ff852e5085b3b593f8ba4677a35a1ca6d1a06e0",
PaymentHash: "e6b1a1379f1d8b7d38ad02b3554b06ca993aa8790a3153f61e30d55d0e4f0d53",
DestinationAddress: "bc1qtest",
LockupAddress: "bc1qlockup",
LockupTxId: "lockuptx",
ClaimTxId: "claimtx",
AutoSwap: false,
TimeoutBlockHeight: 900000,
BoltzPubkey: "02d1a06e08a509f2c8fe3edb2ba10811d030e2f6f3239e9f21203ac954a35a1c",
SwapTree: datatypes.JSON("{}"),
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, swap1)
forward1 := &db.Forward{
OutboundAmountForwardedMsat: 1000000,
TotalFeeEarnedMsat: 1000,
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, forward1)
}
func requireCount[T any](t *testing.T, tx *gorm.DB, expected int64) {
var count int64
var model T
require.NoError(t, tx.Model(&model).Count(&count).Error)
require.Equal(t, expected, count)
}
func create[T any](t *testing.T, tx *gorm.DB, v T) *gorm.DB {

View file

@ -408,7 +408,18 @@ func (cfg *config) SetAutoUnlockPassword(unlockPassword string) error {
func (cfg *config) CheckUnlockPassword(encryptionKey string) bool {
decryptedValue, err := cfg.Get("UnlockPasswordCheck", encryptionKey)
return err == nil && (decryptedValue == "" || decryptedValue == unlockPasswordCheck)
// require a non-empty match so an absent or empty canary always fails
return err == nil && decryptedValue != "" && decryptedValue == unlockPasswordCheck
}
func (cfg *config) IsUnlockPasswordCheckSet() (bool, error) {
// Read the raw value with an empty encryption key so we can detect the
// presence of the canary row without needing the (possibly wrong) password.
value, err := cfg.Get("UnlockPasswordCheck", "")
if err != nil {
return false, fmt.Errorf("read unlock password check: %w", err)
}
return value != "", nil
}
func (cfg *config) SaveUnlockPasswordCheck(encryptionKey string) error {

View file

@ -56,8 +56,6 @@ func TestCheckUnlockPasswordCache(t *testing.T) {
Workdir: ".test",
}, db)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)

View file

@ -6,6 +6,7 @@ const (
PhoenixBackendType = "PHOENIX"
CashuBackendType = "CASHU"
CLNBackendType = "CLN"
BarkBackendType = "BARK"
)
const (
@ -37,6 +38,7 @@ type AppConfig struct {
LDKMaxPathCount uint8 `envconfig:"LDK_MAX_PATH_COUNT" default:"5"`
LDKChannelMonitorWarningSizeBytes uint64 `envconfig:"LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES" default:"5000000"`
LDKVssUrl string `envconfig:"LDK_VSS_URL" default:"https://vss.getalbypro.com/vss"`
LDKLiquiditySourceLsps2 string `envconfig:"LDK_LSPS2_ADDRESSES"`
LDKListeningAddresses string `envconfig:"LDK_LISTENING_ADDRESSES" default:"[::]:9735"`
LDKAnnouncementAddresses string `envconfig:"LDK_ANNOUNCEMENT_ADDRESSES"`
LDKTransientNetworkGraph bool `envconfig:"LDK_TRANSIENT_NETWORK_GRAPH" default:"false"`
@ -63,6 +65,10 @@ type AppConfig struct {
CLNAddress string `envconfig:"CLN_ADDRESS"`
CLNLightningDir string `envconfig:"CLN_LIGHTNING_DIR"`
CLNAddressHold string `envconfig:"CLN_ADDRESS_HOLD"`
BarkServer string `envconfig:"BARK_SERVER" default:"https://ark.second.tech"`
BarkEsploraServer string `envconfig:"BARK_ESPLORA_SERVER" default:"https://mempool.second.tech/api"`
BarkServerAccessToken string `envconfig:"BARK_SERVER_ACCESS_TOKEN"`
BarkLogLevel string `envconfig:"BARK_LOG_LEVEL" default:"3"`
}
func (c *AppConfig) IsDefaultClientId() bool {
@ -88,6 +94,7 @@ type Config interface {
GetMempoolUrl() string
GetEnv() *AppConfig
CheckUnlockPassword(password string) bool
IsUnlockPasswordCheckSet() (bool, error)
ChangeUnlockPassword(currentUnlockPassword string, newUnlockPassword string) error
SetAutoUnlockPassword(unlockPassword string) error
SaveUnlockPasswordCheck(encryptionKey string) error

View file

@ -16,8 +16,6 @@ func TestCheckUnlockPasswordCache_InvalidSecond(t *testing.T) {
require.NoError(t, err)
defer svc.Remove()
err = svc.Cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)
@ -35,8 +33,6 @@ func TestCheckUnlockPasswordCache_InvalidFirst(t *testing.T) {
require.NoError(t, err)
defer svc.Remove()
err = svc.Cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)
@ -58,8 +54,6 @@ func TestCheckUnlockPassword_ChangePassword(t *testing.T) {
require.NoError(t, err)
defer svc.Remove()
err = svc.Cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)
@ -81,6 +75,50 @@ func TestCheckUnlockPassword_ChangePassword(t *testing.T) {
assert.True(t, svc.Cfg.CheckUnlockPassword(newUnlockPassword))
}
func TestCheckUnlockPassword_MissingCanaryFailsClosed(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
// A fresh hub has not saved the unlock-password canary yet.
set, err := svc.Cfg.IsUnlockPasswordCheckSet()
require.NoError(t, err)
assert.False(t, set)
// Without the canary, no password may validate - including an empty one.
assert.False(t, svc.Cfg.CheckUnlockPassword(""))
assert.False(t, svc.Cfg.CheckUnlockPassword("any-password"))
// After the canary is saved, only the correct password validates.
err = svc.Cfg.SaveUnlockPasswordCheck("correct")
require.NoError(t, err)
set, err = svc.Cfg.IsUnlockPasswordCheckSet()
require.NoError(t, err)
assert.True(t, set)
assert.True(t, svc.Cfg.CheckUnlockPassword("correct"))
assert.False(t, svc.Cfg.CheckUnlockPassword("wrong"))
assert.False(t, svc.Cfg.CheckUnlockPassword(""))
}
func TestCheckUnlockPassword_NoPasswordHub(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
// A hub configured without an unlock password stores the canary unencrypted;
// the empty password must still validate after the fail-closed change.
err = svc.Cfg.SaveUnlockPasswordCheck("")
require.NoError(t, err)
set, err := svc.Cfg.IsUnlockPasswordCheckSet()
require.NoError(t, err)
assert.True(t, set)
assert.True(t, svc.Cfg.CheckUnlockPassword(""))
}
func TestSetIgnore_NoEncryptionKey(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
@ -219,7 +257,7 @@ func TestJWTSecret_GeneratedOnLoad(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", "123")
err = cfg.SaveUnlockPasswordCheck("123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
@ -251,9 +289,6 @@ func TestJWTSecret_WrongPassword(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck("123")
require.NoError(t, err)
@ -272,7 +307,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", "123")
err = cfg.SaveUnlockPasswordCheck("123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
@ -282,7 +317,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, jwtSecret)
err = cfg.ChangeUnlockPassword("", "1234")
err = cfg.ChangeUnlockPassword("123", "1234")
require.NoError(t, err)
newJwtSecret, err := cfg.GetJWTSecret()
@ -306,7 +341,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", "123")
err = cfg.SaveUnlockPasswordCheck("123")
require.NoError(t, err)
// simulate a hub that had an unencrypted JWT secret

235
db/db_migrate.go Normal file
View file

@ -0,0 +1,235 @@
package db
import (
"fmt"
"slices"
"gorm.io/gorm"
"github.com/getAlby/hub/logger"
)
var expectedTables = []string{
"apps",
"app_permissions",
"request_events",
"response_events",
"transactions",
"swaps",
"user_configs",
"migrations",
"forwards",
}
// MigrateDB copies all rows from one database to another. Both databases
// must have an up-to-date schema (they are checked against expectedTables).
// Orphaned request and response events are deleted from the source database
// before copying, as they would violate foreign key constraints in the
// destination database.
func MigrateDB(from, to *gorm.DB) error {
if err := checkSchema(from); err != nil {
return fmt.Errorf("source database schema check failed: %w", err)
}
if err := checkSchema(to); err != nil {
return fmt.Errorf("destination database schema check failed: %w", err)
}
// NOTE: we assume that excess request events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned request events.")
err := from.Exec("DELETE FROM request_events WHERE app_id NOT IN (SELECT id FROM apps);").Error
if err != nil {
return fmt.Errorf("failed to delete orphaned request events: %w", err)
}
// NOTE: we assume that excess response events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned response events.")
err = from.Exec("DELETE FROM response_events WHERE request_id NOT IN (SELECT id FROM request_events);").Error
if err != nil {
return fmt.Errorf("failed to delete orphaned response events: %w", err)
}
tx := to.Begin()
defer tx.Rollback()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
// Table migration order matters: referenced tables must be migrated
// before referencing tables.
logger.Logger.Info("migrating apps...")
if err := migrateTable[App](from, tx); err != nil {
return fmt.Errorf("failed to migrate apps: %w", err)
}
logger.Logger.Info("migrating app_permissions...")
if err := migrateTable[AppPermission](from, tx); err != nil {
return fmt.Errorf("failed to migrate app_permissions: %w", err)
}
logger.Logger.Info("migrating request_events...")
if err := migrateTable[RequestEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate request_events: %w", err)
}
logger.Logger.Info("migrating response_events...")
if err := migrateTable[ResponseEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate response_events: %w", err)
}
logger.Logger.Info("migrating transactions...")
if err := migrateTable[Transaction](from, tx); err != nil {
return fmt.Errorf("failed to migrate transactions: %w", err)
}
logger.Logger.Info("migrating swaps...")
if err := migrateTable[Swap](from, tx); err != nil {
return fmt.Errorf("failed to migrate swaps: %w", err)
}
logger.Logger.Info("migrating forwards...")
if err := migrateTable[Forward](from, tx); err != nil {
return fmt.Errorf("failed to migrate forwards: %w", err)
}
logger.Logger.Info("migrating user_configs...")
if err := migrateTable[UserConfig](from, tx); err != nil {
return fmt.Errorf("failed to migrate user_configs: %w", err)
}
if to.Dialector.Name() == "postgres" {
logger.Logger.Info("resetting sequences...")
if err := resetSequences(tx); err != nil {
return fmt.Errorf("failed to reset sequences: %w", err)
}
}
tx.Commit()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func migrateTable[T any](from, to *gorm.DB) error {
var data []T
if err := from.Find(&data).Error; err != nil {
return fmt.Errorf("failed to fetch data: %w", err)
}
if len(data) == 0 {
return nil
}
// to avoid "failed to migrate transactions: failed to insert data: extended protocol limited to 65535 parameters"
// see https://stackoverflow.com/questions/77372430/extended-protocol-limited-to-65535-parameters-golang-gorm
// max statements is 65535
// but it's the number of records * columns
// to be safe, using a lower value of 1000.
// this will fail if any table has more than 65 columns, which I doubt we will have
max := 1000
for i := 0; i < len(data); i += max {
j := min(i+max, len(data))
if err := to.Create(data[i:j]).Error; err != nil {
return fmt.Errorf("failed to insert data: %w", err)
}
}
return nil
}
func checkSchema(db *gorm.DB) error {
tables, err := listTables(db)
if err != nil {
return fmt.Errorf("failed to list database tables: %w", err)
}
for _, table := range expectedTables {
if !slices.Contains(tables, table) {
return fmt.Errorf("table missing from the database: %q", table)
}
}
for _, table := range tables {
if !slices.Contains(expectedTables, table) {
return fmt.Errorf("unexpected table found in the database: %q", table)
}
}
return nil
}
func listTables(db *gorm.DB) ([]string, error) {
var query string
switch db.Dialector.Name() {
case "sqlite":
query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"
case "postgres":
query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';"
default:
return nil, fmt.Errorf("unsupported database: %q", db.Dialector.Name())
}
rows, err := db.Raw(query).Rows()
if err != nil {
return nil, fmt.Errorf("failed to query table names: %w", err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Logger.WithError(err).Error("failed to close rows")
}
}()
var tables []string
for rows.Next() {
var table string
if err := rows.Scan(&table); err != nil {
return nil, fmt.Errorf("failed to scan table name: %w", err)
}
tables = append(tables, table)
}
return tables, nil
}
func resetSequences(db *gorm.DB) error {
type resetReq struct {
table string
seq string
}
resetReqs := []resetReq{
{"apps", "apps_2_id_seq"},
{"app_permissions", "app_permissions_2_id_seq"},
{"request_events", "request_events_id_seq"},
{"response_events", "response_events_id_seq"},
{"transactions", "transactions_id_seq"},
{"swaps", "swaps_id_seq"},
{"forwards", "forwards_id_seq"},
{"user_configs", "user_configs_id_seq"},
}
for _, req := range resetReqs {
if err := resetPostgresSequence(db, req.table, req.seq); err != nil {
return fmt.Errorf("failed to reset sequence %q for %q: %w", req.seq, req.table, err)
}
}
return nil
}
func resetPostgresSequence(db *gorm.DB, table string, seq string) error {
query := fmt.Sprintf("SELECT setval('%s', (SELECT MAX(id) FROM %s));", seq, table)
if err := db.Exec(query).Error; err != nil {
return fmt.Errorf("failed to execute setval(): %w", err)
}
return nil
}

View file

@ -20,14 +20,13 @@
"prepare": "cd .. && husky frontend/.husky"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/inter": "^5.2.8",
"@base-ui/react": "^1.5.0",
"@fontsource-variable/figtree": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@getalby/lightning-tools": "^8.1.0",
"@getalby/sdk": "^7.0.0",
"@getalby/sdk": "^8.0.3",
"@scure/bip39": "^2.2.0",
"@stepperize/react": "^6.1.0",
"argon2-wasm-esm": "^1.0.3",
"bitcoin-address-validation": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -36,36 +35,35 @@
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.7.0",
"lottie-react": "^2.4.1",
"lucide-react": "^1.28.0",
"qr-code-styling": "^1.9.2",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.6",
"react-lottie": "^1.2.4",
"react-qr-code": "^2.0.12",
"react-router": "^7.14.2",
"react-router": "^7.18.2",
"sonner": "^2.0.7",
"swr": "^2.4.1",
"tailwind-merge": "^3.4.1",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.0",
"@commitlint/config-conventional": "^21.2.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.2",
"@types/node": "^25.9.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.0.0",
"@types/react-lottie": "^1.2.10",
"@vitejs/plugin-react-swc": "^4.3.0",
"eslint": "^10.3.0",
"@vitejs/plugin-react-swc": "^4.3.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
@ -74,12 +72,11 @@
"lint-staged": "^16.4.0",
"prettier": "3.8.3",
"shx": "^0.4.0",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.2",
"vite": "^5.4.0",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^6.1.1"
"typescript-eslint": "^8.61.0",
"vite": "^8.2.1",
"vite-plugin-pwa": "^1.3.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View file

@ -10,7 +10,7 @@ export const request = async <T>(
args[1]?.body?.toString() || ""
);
console.info("Wails request", ...args, res);
console.info("Wails request", args[0].toString(), args[1]?.method || "GET");
if (res.error) {
throw new Error(res.error);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,12 @@
<svg width="672" height="660" viewBox="0 0 672 660" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_531_1058)">
<path d="M672 0H0V660H672V0Z" fill="#070707"/>
<path d="M214.167 255.106L137.061 267.979C124.805 270.011 117.322 282.607 121.387 294.339C138.046 342.411 159.232 374.006 183.343 370.188C203.543 366.985 224.822 325.566 236.923 280.081C240.68 265.977 228.609 252.704 214.198 255.106H214.167Z" fill="white"/>
<path d="M446.871 660L443.391 634.471C442.191 625.664 437.787 617.595 431.074 611.806C366.47 556.251 324.961 499.065 300.142 457.03C296.569 450.963 304.299 444.589 309.564 449.3C375.338 508.057 475.663 569.586 525.794 535.064C584.086 494.938 561.175 374.037 505.994 270.535C503.561 266.008 487.703 239.216 484.039 233.427C450.813 180.736 368.841 51.4279 330.103 72.1529C301.496 87.4581 336.939 158.533 351.782 207.066C354.245 215.073 350.673 223.695 343.283 227.637C337.678 230.624 332.413 234.104 323.052 240.232C239.109 298.035 208.778 329.723 256.785 393.53C261.373 399.628 261.342 408.004 256.6 414.009C228.055 450.101 127.268 554.373 112.057 660M515.94 462.758C525.271 476.307 523.885 504.947 512.769 512.584C501.683 520.221 476.155 510.12 466.825 496.571C457.495 483.021 466.764 474.798 480.343 465.468C493.892 456.137 506.61 449.177 515.94 462.758ZM442.375 317.282C455.154 306.042 473.723 304.933 490.844 314.387C493.738 315.988 495.801 318.606 496.725 321.747C497.649 324.919 497.249 328.245 495.678 331.109C494.077 334.003 491.46 336.098 488.288 336.991C485.116 337.914 481.791 337.514 478.927 335.944C471.167 331.663 463.407 331.601 458.634 335.79C454.354 339.547 453.461 345.952 456.232 352.942C457.433 355.991 457.372 359.348 456.078 362.366C454.785 365.384 452.352 367.724 449.304 368.925C447.856 369.51 446.348 369.787 444.777 369.787C439.696 369.787 435.17 366.739 433.322 361.996C426.671 345.182 430.15 328.06 442.375 317.282ZM348.579 385.092C361.358 373.852 379.927 372.744 397.048 382.198C399.942 383.799 402.005 386.417 402.929 389.558C403.853 392.73 403.453 396.055 401.882 398.919C400.281 401.814 397.664 403.908 394.492 404.801C391.32 405.725 387.995 405.325 385.131 403.754C377.371 399.474 369.611 399.412 364.838 403.6C360.558 407.357 359.665 413.763 362.436 420.753C364.93 427.066 361.82 434.241 355.508 436.736C354.06 437.321 352.521 437.598 350.981 437.598C345.9 437.598 341.404 434.549 339.526 429.807C332.875 412.993 336.354 395.84 348.579 385.092Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_531_1058">
<rect width="672" height="660" rx="330" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,12 @@
<svg width="672" height="660" viewBox="0 0 672 660" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_531_1063)">
<path d="M672 0H0V660H672V0Z" fill="white"/>
<path d="M214.167 255.106L137.061 267.979C124.805 270.011 117.322 282.607 121.387 294.339C138.046 342.411 159.232 374.006 183.343 370.188C203.543 366.985 224.822 325.566 236.923 280.081C240.68 265.977 228.609 252.704 214.198 255.106H214.167Z" fill="#060606"/>
<path d="M446.871 660L443.391 634.471C442.191 625.664 437.787 617.595 431.074 611.806C366.47 556.251 324.961 499.065 300.142 457.03C296.569 450.963 304.299 444.589 309.564 449.3C375.338 508.057 475.663 569.586 525.794 535.064C584.086 494.938 561.175 374.037 505.994 270.535C503.561 266.008 487.703 239.216 484.039 233.427C450.813 180.736 368.841 51.4279 330.103 72.1529C301.496 87.4581 336.939 158.533 351.782 207.066C354.245 215.073 350.673 223.695 343.283 227.637C337.678 230.624 332.413 234.104 323.052 240.232C239.109 298.035 208.778 329.723 256.785 393.53C261.373 399.628 261.342 408.004 256.6 414.009C228.055 450.101 127.268 554.373 112.057 660M515.94 462.758C525.271 476.307 523.885 504.947 512.769 512.584C501.683 520.221 476.155 510.12 466.825 496.571C457.495 483.021 466.764 474.798 480.343 465.468C493.892 456.137 506.61 449.177 515.94 462.758ZM442.375 317.282C455.154 306.042 473.723 304.933 490.844 314.387C493.738 315.988 495.801 318.606 496.725 321.747C497.649 324.919 497.249 328.245 495.678 331.109C494.077 334.003 491.46 336.098 488.288 336.991C485.116 337.914 481.791 337.514 478.927 335.944C471.167 331.663 463.407 331.601 458.634 335.79C454.354 339.547 453.461 345.952 456.232 352.942C457.433 355.991 457.372 359.348 456.078 362.366C454.785 365.384 452.352 367.724 449.304 368.925C447.856 369.51 446.348 369.787 444.777 369.787C439.696 369.787 435.17 366.739 433.322 361.996C426.671 345.182 430.15 328.06 442.375 317.282ZM348.579 385.092C361.358 373.852 379.927 372.744 397.048 382.198C399.942 383.799 402.005 386.417 402.929 389.558C403.853 392.73 403.453 396.055 401.882 398.919C400.281 401.814 397.664 403.908 394.492 404.801C391.32 405.725 387.995 405.325 385.131 403.754C377.371 399.474 369.611 399.412 364.838 403.6C360.558 407.357 359.665 413.763 362.436 420.753C364.93 427.066 361.82 434.241 355.508 436.736C354.06 437.321 352.521 437.598 350.981 437.598C345.9 437.598 341.404 434.549 339.526 429.807C332.875 412.993 336.354 395.84 348.579 385.092Z" fill="#060606"/>
</g>
<defs>
<clipPath id="clip0_531_1063">
<rect width="672" height="660" rx="330" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -32,12 +32,14 @@ export function AnchorReserveAlert({
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Channel Anchor Reserves will be depleted</AlertTitle>
<AlertDescription>
You have channels open and by spending your entire on-chain balance
including your anchor reserves may put your node at risk of unable to
reclaim funds in your channel after a force-closure. To prevent this,
set aside at least{" "}
<FormattedBitcoinAmount amountMsat={channels.length * 25000 * 1000} />{" "}
on-chain.
<p>
You have channels open and by spending your entire on-chain balance
including your anchor reserves may put your node at risk of unable to
reclaim funds in your channel after a force-closure. To prevent this,
set aside at least{" "}
<FormattedBitcoinAmount amountMsat={channels.length * 25000 * 1000} />{" "}
on-chain.
</p>
</AlertDescription>
</Alert>
);

View file

@ -4,6 +4,7 @@ import codexLogo from "src/assets/suggested-apps/codex.png";
import cursorLogo from "src/assets/suggested-apps/cursor.png";
import geminiLogo from "src/assets/suggested-apps/gemini.png";
import gooseLogo from "src/assets/suggested-apps/goose.png";
import hermesLogo from "src/assets/suggested-apps/hermes.png";
import openclawLogo from "src/assets/suggested-apps/openclaw.png";
import opencodeLogo from "src/assets/suggested-apps/opencode.png";
import { appStoreApps } from "src/components/connections/SuggestedAppData";
@ -17,6 +18,7 @@ import { App } from "src/types";
const agentLogos: Record<string, string> = {
claude: claudeLogo,
goose: gooseLogo,
hermes: hermesLogo,
openclaw: openclawLogo,
cursor: cursorLogo,
codex: codexLogo,

View file

@ -2,6 +2,7 @@ import {
BotIcon,
BoxIcon,
ChevronsUpDownIcon,
CreditCardIcon,
CircleHelpIcon,
HandCoinsIcon,
HomeIcon,
@ -105,6 +106,11 @@ export function AppSidebar() {
title: "AI & Agents",
url: "/ai",
icon: BotIcon,
},
{
title: "Cards",
url: "/cards",
icon: CreditCardIcon,
badge: "NEW",
},
],

View file

@ -0,0 +1,528 @@
import * as React from "react";
import { toast } from "sonner";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "src/components/ui/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "src/components/ui/input-group";
import { Skeleton } from "src/components/ui/skeleton";
import { BITCOIN_DISPLAY_FORMAT_BIP177 } from "src/constants";
import { useBitcoinRate } from "src/hooks/useBitcoinRate";
import { useInfo } from "src/hooks/useInfo";
import { cn } from "src/lib/utils";
type CurrencyInputMode = "bitcoin" | "fiat";
type BitcoinDenomination = "sats" | "btc";
export type CurrencyInputContextRow = {
label: string;
amountSat?: number | null;
value?: React.ReactNode;
};
type CurrencyInputFieldProps = Omit<
React.ComponentProps<typeof InputGroupInput>,
"max" | "min" | "onChange" | "step" | "type" | "value"
> & {
contextRows?: CurrencyInputContextRow[];
description?: React.ReactNode;
error?: React.ReactNode;
label?: React.ReactNode;
maxSat?: number;
minSat?: number;
onValueSatChange: (valueSat: string) => void;
valueSat: string;
};
const SATS_PER_BTC = 100_000_000;
function getNumericValue(value: string | number | null | undefined) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getCurrencyFractionDigits(currency: string) {
try {
return new Intl.NumberFormat("en-US", {
currency,
style: "currency",
}).resolvedOptions().maximumFractionDigits;
} catch {
return 2;
}
}
function getCurrencySymbol(currency: string) {
try {
return (
new Intl.NumberFormat("en-US", {
currency,
style: "currency",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value || currency
);
} catch {
return currency;
}
}
function formatFiatValue(
amountSat: string | number | undefined,
rate: number | undefined,
currency: string | undefined
) {
if (!rate || !currency) {
return null;
}
return new Intl.NumberFormat("en-US", {
currency,
style: "currency",
}).format((getNumericValue(amountSat) / SATS_PER_BTC) * rate);
}
function formatFiatInput(amountSat: string, rate: number, currency: string) {
const fractionDigits = getCurrencyFractionDigits(currency);
const amountFiat = (getNumericValue(amountSat) / SATS_PER_BTC) * rate;
if (!amountFiat) {
return "";
}
return amountFiat.toFixed(fractionDigits);
}
function formatBitcoinValue(
amountSat: string | number | null | undefined,
displayFormat: string | undefined,
denomination: BitcoinDenomination = "sats"
) {
const { amount, unit } = formatBitcoinValueParts(
amountSat,
displayFormat,
denomination
);
if (unit === "₿") {
return `${unit}${amount}`;
}
return `${amount} ${unit}`;
}
function formatBitcoinValueParts(
amountSat: string | number | null | undefined,
displayFormat: string | undefined,
denomination: BitcoinDenomination = "sats"
) {
if (denomination === "btc") {
return {
amount: formatBtcDisplay(amountSat),
unit: "BTC",
};
}
const formattedAmount = new Intl.NumberFormat().format(
Math.floor(getNumericValue(amountSat))
);
if (displayFormat === BITCOIN_DISPLAY_FORMAT_BIP177) {
return {
amount: formattedAmount,
unit: "₿",
};
}
return {
amount: formattedAmount,
unit: "sats",
};
}
function BitcoinValueText({
amountSat,
denomination,
displayFormat,
}: {
amountSat: string | number | null | undefined;
denomination: BitcoinDenomination;
displayFormat: string | undefined;
}) {
const { amount, unit } = formatBitcoinValueParts(
amountSat,
displayFormat,
denomination
);
return (
<span className="inline-flex min-w-0 items-center justify-end gap-1">
{unit === "₿" && <span>{unit}</span>}
<span className="min-w-0 truncate">{amount}</span>
{unit !== "₿" && <span>{unit}</span>}
</span>
);
}
function formatBtcDisplay(amountSat: string | number | null | undefined) {
return (getNumericValue(amountSat) / SATS_PER_BTC).toFixed(8);
}
function formatBtcInput(amountSat: string | number | null | undefined) {
const amount = getNumericValue(amountSat);
if (!amount) {
return "";
}
return (amount / SATS_PER_BTC).toFixed(8);
}
export function CurrencyInputField({
className,
contextRows,
description,
disabled,
error,
id,
label = "Amount",
maxSat,
minSat,
onValueSatChange,
required,
valueSat,
...props
}: CurrencyInputFieldProps) {
const generatedId = React.useId();
const { data: info } = useInfo();
const { data: bitcoinRate, error: bitcoinRateError } = useBitcoinRate(
info?.currency
);
const [mode, setMode] = React.useState<CurrencyInputMode>("bitcoin");
const [fiatValue, setFiatValue] = React.useState("");
const [bitcoinDenomination, setBitcoinDenomination] =
React.useState<BitcoinDenomination>("sats");
const [btcValue, setBtcValue] = React.useState("");
const currency = info?.currency || "USD";
const rate = bitcoinRate?.rate_float;
const canUseFiat = currency !== "SATS" && !!rate && !bitcoinRateError;
const bitcoinUnit =
info?.bitcoinDisplayFormat === BITCOIN_DISPLAY_FORMAT_BIP177 ? "₿" : "sats";
const invalid =
props["aria-invalid"] === true ||
props["aria-invalid"] === "true" ||
!!error;
const inputId = id || generatedId;
const isFiatMode = mode === "fiat";
const isBtcDenominated = bitcoinDenomination === "btc";
const inputValue = isFiatMode
? fiatValue
: isBtcDenominated
? btcValue
: valueSat;
const alternateBitcoinValue = formatBitcoinValueParts(
valueSat,
info?.bitcoinDisplayFormat,
bitcoinDenomination
);
const alternateValue = isFiatMode
? formatBitcoinValue(
valueSat,
info?.bitcoinDisplayFormat,
bitcoinDenomination
)
: formatFiatValue(valueSat, rate, currency);
React.useEffect(() => {
if (mode === "fiat" && !valueSat) {
setFiatValue("");
}
}, [mode, valueSat]);
React.useEffect(() => {
if (mode === "bitcoin" && isBtcDenominated && !valueSat) {
setBtcValue("");
}
}, [isBtcDenominated, mode, valueSat]);
function handleToggleMode() {
if (disabled) {
return;
}
if (mode === "bitcoin") {
if (!canUseFiat) {
return;
}
setFiatValue(formatFiatInput(valueSat, rate, currency));
setMode("fiat");
return;
}
if (isBtcDenominated) {
setBtcValue(formatBtcInput(valueSat));
}
setMode("bitcoin");
}
function handleAlternateValueClick() {
if (disabled || isFiatMode || !canUseFiat) {
return;
}
handleToggleMode();
}
function handleToggleBitcoinDenomination() {
if (disabled) {
return;
}
if (isBtcDenominated) {
setBitcoinDenomination("sats");
return;
}
setBtcValue(formatBtcInput(valueSat));
setBitcoinDenomination("btc");
}
function handleChangeMode(event: React.ChangeEvent<HTMLInputElement>) {
// clear any custom validity set via onInvalid so the field re-validates
// on the next submit
event.currentTarget.setCustomValidity("");
const nextValue = event.target.value.trim();
if (mode === "bitcoin") {
if (!isBtcDenominated && nextValue.includes(".")) {
setBitcoinDenomination("btc");
setBtcValue(nextValue);
toast("Switched to BTC for decimal amount");
if (!nextValue) {
onValueSatChange("");
return;
}
const amountBtc = Number(nextValue);
if (!Number.isFinite(amountBtc)) {
onValueSatChange("");
return;
}
onValueSatChange(
Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString()
);
return;
}
if (isBtcDenominated) {
setBtcValue(nextValue);
if (!nextValue) {
onValueSatChange("");
return;
}
const amountBtc = Number(nextValue);
if (!Number.isFinite(amountBtc)) {
onValueSatChange("");
return;
}
onValueSatChange(
Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString()
);
return;
}
onValueSatChange(nextValue);
return;
}
setFiatValue(nextValue);
if (!nextValue || !rate) {
onValueSatChange("");
return;
}
const amountFiat = Number(nextValue);
if (!Number.isFinite(amountFiat)) {
onValueSatChange("");
return;
}
onValueSatChange(
Math.max(0, Math.round((amountFiat / rate) * SATS_PER_BTC)).toString()
);
}
function getModeBound(amountSat: number | undefined) {
if (amountSat === undefined) {
return undefined;
}
if (!isFiatMode) {
if (isBtcDenominated) {
return amountSat / SATS_PER_BTC;
}
return amountSat;
}
if (!rate) {
return amountSat;
}
return ((amountSat / SATS_PER_BTC) * rate).toFixed(
getCurrencyFractionDigits(currency)
);
}
return (
<Field
className={cn("w-full min-w-0", className)}
data-disabled={disabled || undefined}
data-invalid={invalid || undefined}
>
{label && <FieldLabel htmlFor={inputId}>{label}</FieldLabel>}
<InputGroup className="h-9 min-w-0 overflow-hidden has-[>[data-align=inline-start]]:[&>input]:pl-1">
<InputGroupInput
{...props}
id={inputId}
aria-invalid={invalid || undefined}
autoComplete="off"
className={cn(
"sensitive slashed-zero min-w-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
)}
disabled={disabled}
inputMode="decimal"
max={getModeBound(maxSat)}
min={getModeBound(minSat)}
onChange={handleChangeMode}
placeholder={
isFiatMode ? "0.00" : isBtcDenominated ? "0.00000000" : "0"
}
required={required}
step={isFiatMode ? "any" : isBtcDenominated ? 0.00000001 : 1}
type="number"
value={inputValue}
/>
<InputGroupAddon align="inline-start">
{isFiatMode ? (
<InputGroupButton
aria-label="Enter amount in bitcoin"
disabled={disabled}
onClick={handleToggleMode}
size="xs"
className="h-full rounded-none bg-transparent pl-2 pr-0 text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
title="Enter amount in bitcoin"
>
{getCurrencySymbol(currency)}
</InputGroupButton>
) : (
<InputGroupButton
aria-label={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
aria-pressed={isBtcDenominated}
disabled={disabled}
onClick={handleToggleBitcoinDenomination}
size="xs"
className="h-full rounded-none bg-transparent pl-2 pr-0 text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
title={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
>
{isBtcDenominated ? "BTC" : bitcoinUnit}
</InputGroupButton>
)}
</InputGroupAddon>
<InputGroupAddon
align="inline-end"
className="mr-0 min-w-0 self-stretch py-0 pr-4"
>
{isFiatMode ? (
<InputGroupButton
aria-label={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
aria-pressed={isBtcDenominated}
disabled={disabled}
onClick={handleToggleBitcoinDenomination}
size="xs"
className="sensitive slashed-zero h-full min-w-0 justify-end truncate rounded-none bg-transparent px-0.5 text-muted-foreground tabular-nums hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
title={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
>
{alternateBitcoinValue.unit === "₿" && (
<span>{alternateBitcoinValue.unit}</span>
)}
<span className="min-w-0 truncate">
{alternateBitcoinValue.amount}
</span>
{alternateBitcoinValue.unit !== "₿" && (
<span>{alternateBitcoinValue.unit}</span>
)}
</InputGroupButton>
) : (
<InputGroupButton
aria-label="Enter amount in fiat"
disabled={disabled || !canUseFiat}
onClick={handleAlternateValueClick}
size="xs"
className="sensitive slashed-zero h-full min-w-0 max-w-28 justify-end truncate rounded-none bg-transparent px-1 text-muted-foreground tabular-nums hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0 sm:max-w-none"
title="Enter amount in fiat"
>
{alternateValue ?? <Skeleton className="h-4 w-16" />}
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
{!!contextRows?.length && (
<div className="flex min-w-0 cursor-default flex-col gap-1 text-sm text-muted-foreground">
{contextRows.map((row) => (
<div
className="flex min-w-0 items-center justify-between gap-3"
key={row.label}
>
<span className="truncate">{row.label}:</span>
<span className="sensitive slashed-zero min-w-0 max-w-[55%] truncate text-right tabular-nums">
{row.value ?? (
<BitcoinValueText
amountSat={row.amountSat}
displayFormat={info?.bitcoinDisplayFormat}
denomination={bitcoinDenomination}
/>
)}
</span>
</div>
))}
</div>
)}
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError>{error}</FieldError>}
</Field>
);
}

View file

@ -3,37 +3,44 @@ import React from "react";
import { LinkButton } from "src/components/ui/custom/link-button";
import { cn } from "src/lib/utils";
interface Props {
type Variant = "dashed" | "muted" | "none";
type Props = {
icon: LucideIcon;
title: string;
description: string;
buttonText: string;
buttonLink: string;
showButton?: boolean;
showBorder?: boolean;
}
variant?: Variant;
} & (
| { buttonText: string; buttonLink: string }
| { buttonText?: never; buttonLink?: never }
);
const variantClasses: Record<Variant, string> = {
dashed: "shadow-xs border border-dashed",
muted: "bg-muted",
none: "",
};
const EmptyState: React.FC<Props> = ({
icon: Icon,
title: message,
description: subMessage,
variant = "muted",
buttonText,
buttonLink,
showButton = true,
showBorder = true,
}) => {
return (
<div
className={cn(
"flex flex-1 items-center justify-center rounded-lg p-8",
showBorder && "shadow-xs border border-dashed"
variantClasses[variant]
)}
>
<div className="flex flex-col items-center gap-1 text-center max-w-sm">
<Icon className="w-10 h-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">{message}</h3>
<p className="text-sm text-muted-foreground">{subMessage}</p>
{showButton && (
{buttonText && buttonLink && (
<LinkButton to={buttonLink} className="mt-4">
{buttonText}
</LinkButton>

View file

@ -0,0 +1,183 @@
import { AlertTriangleIcon, InfoIcon } from "lucide-react";
import React from "react";
import { Link } from "react-router";
import ExternalLink from "src/components/ExternalLink";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import { useBalances } from "src/hooks/useBalances";
import { useChannels } from "src/hooks/useChannels";
import { useInfo } from "src/hooks/useInfo";
import { CreateInvoiceRequest, Transaction } from "src/types";
import { request } from "src/utils/request";
const PROBE_TIMEOUT_MS = 5000;
export default function FirstChannelJitAlert() {
const { data: info } = useInfo();
const { data: channels } = useChannels();
const { data: balances } = useBalances();
// a JIT channel only opens when the feature is enabled AND an LSPS2 liquidity
// source is actually configured (jitChannelsEnabled alone is just a settings
// toggle and can be true on backends without an LSPS2 source).
const lsps2Source = info?.jitChannelsEnabled
? info.jitChannelsLiquiditySource
: undefined;
const minPaymentSizeMsat = info?.jitChannelsMinPaymentSizeMsat;
const isJitEnabled = !!lsps2Source && !!channels;
// the user's first received payment opens the channel when they have none yet.
const isFirstChannel = isJitEnabled && channels.length === 0;
// probe whether a JIT channel can actually be obtained by requesting an
// invoice for the minimum payment size. If it (or waiting for the minimum
// payment size) doesn't succeed within the timeout, we surface a fallback
// alert depending on whether the user already has channels.
const [probeState, setProbeState] = React.useState<
"loading" | "ok" | "failed"
>("loading");
const deadlineRef = React.useRef<number | null>(null);
// single-flight the non-idempotent probe invoice: hold the in-flight request
// so effect re-entry (e.g. StrictMode remount) reuses the same POST instead
// of creating a duplicate invoice. Reset when the probe window ends.
const probeRequestRef = React.useRef<Promise<Transaction | undefined> | null>(
null
);
React.useEffect(() => {
if (!isJitEnabled) {
deadlineRef.current = null;
probeRequestRef.current = null;
return;
}
// start the 5s clock once when we enter JIT mode - it keeps ticking while
// we wait for the minimum payment size to become available.
if (deadlineRef.current === null) {
deadlineRef.current = Date.now() + PROBE_TIMEOUT_MS;
}
let cancelled = false;
const remainingMs = deadlineRef.current - Date.now();
if (remainingMs <= 0) {
setProbeState("failed");
return;
}
const timer = setTimeout(() => {
if (!cancelled) {
setProbeState("failed");
}
}, remainingMs);
// wait for the minimum payment size before requesting the probe invoice.
if (minPaymentSizeMsat) {
// reuse an already in-flight probe so a re-run doesn't issue a second POST.
const probeRequest =
probeRequestRef.current ??
(probeRequestRef.current = request<Transaction>("/api/invoices", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
amountMsat: minPaymentSizeMsat,
description: "",
} as CreateInvoiceRequest),
}));
probeRequest
.then(() => {
if (!cancelled) {
clearTimeout(timer);
setProbeState("ok");
}
})
.catch(() => {
if (!cancelled) {
clearTimeout(timer);
setProbeState("failed");
}
});
}
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [isJitEnabled, minPaymentSizeMsat]);
if (!isJitEnabled || probeState === "loading") {
return null;
}
if (probeState === "failed") {
// no channels yet and a JIT channel couldn't be obtained - the user has no
// receiving capacity at all and will fail to receive until a channel opens.
if (isFirstChannel) {
return (
<Alert variant="warning">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Can't receive payments yet</AlertTitle>
<AlertDescription className="inline">
You won't be able to receive payments until you{" "}
<Link className="underline" to="/channels/incoming">
open a channel
</Link>
.
</AlertDescription>
</Alert>
);
}
// they already have channels but a JIT channel couldn't be obtained, so they
// can only receive up to their current capacity without opening one. Wait for
// balances so we don't claim a misleading "0" receivable amount.
if (!balances) {
return null;
}
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription className="inline">
You can currently receive up to{" "}
<FormattedBitcoinAmount
amountMsat={balances.lightning.totalReceivableMsat}
/>
. If you want to receive a larger payment,{" "}
<Link className="underline" to="/channels/incoming">
open a channel
</Link>
.
</AlertDescription>
</Alert>
);
}
// probe succeeded - only the first-channel case needs an informational alert.
if (!isFirstChannel) {
return null;
}
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle>First payment opens a channel</AlertTitle>
<AlertDescription className="inline">
A channel fee applies.{" "}
{!!minPaymentSizeMsat && (
<>
Minimum payment{" "}
<FormattedBitcoinAmount amountMsat={minPaymentSizeMsat} />.{" "}
</>
)}
<ExternalLink
to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
className="underline"
>
Learn more
</ExternalLink>
</AlertDescription>
</Alert>
);
}

View file

@ -4,12 +4,12 @@ import {
ExternalLinkIcon,
HandCoinsIcon,
} from "lucide-react";
import TickSVG from "public/images/illustrations/tick.svg";
import { useEffect, useState } from "react";
import { FixedFloatButton } from "src/components/FixedFloatButton";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import LottieLoading from "src/components/LottieLoading";
import LottieSuccess from "src/components/LottieSuccess";
import { Button } from "src/components/ui/button";
import {
Card,
@ -77,7 +77,7 @@ export function FixedFloatSwapInFlow({
return (
<Card>
<CardHeader>
<CardTitle className="text-center">Waiting for Payment</CardTitle>
<CardTitle className="text-center">Waiting for Payment...</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-6">
<LottieLoading size={288} />
@ -91,7 +91,7 @@ export function FixedFloatSwapInFlow({
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2 pt-2">
<CardFooter className="flex flex-col gap-3 pt-2">
<Button
type="button"
className="w-full"
@ -123,7 +123,7 @@ export function FixedFloatSwapInFlow({
<CardTitle className="text-center">Transaction Received!</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-6">
<img src={TickSVG} className="w-48" />
<LottieSuccess />
<div className="flex flex-col gap-1 items-center">
<p className="text-2xl font-medium slashed-zero">
<FormattedBitcoinAmount amountMsat={transaction.amountMsat} />
@ -134,7 +134,7 @@ export function FixedFloatSwapInFlow({
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2 pt-2">
<CardFooter className="flex flex-col gap-3 pt-2">
<Button
type="button"
onClick={() => {
@ -144,11 +144,11 @@ export function FixedFloatSwapInFlow({
variant="outline"
className="w-full"
>
<HandCoinsIcon className="w-4 h-4 mr-2" />
<HandCoinsIcon className="size-4" />
{resetLabel}
</Button>
<LinkButton to="/wallet" variant="link" className="w-full">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
<ArrowLeftIcon className="size-4" />
Back to Wallet
</LinkButton>
</CardFooter>

View file

@ -1,5 +1,4 @@
import { useMemo } from "react";
import Lottie from "react-lottie";
import Lottie from "lottie-react";
import animationDataDark from "src/assets/lotties/loading-dark.json";
import animationDataLight from "src/assets/lotties/loading-light.json";
import { useTheme } from "src/components/ui/theme-provider";
@ -7,15 +6,13 @@ import { useTheme } from "src/components/ui/theme-provider";
export default function LottieLoading({ size }: { size?: number }) {
const { isDarkMode } = useTheme();
const options = useMemo(
() => ({
loop: true,
autoplay: true,
animationData: isDarkMode ? animationDataDark : animationDataLight,
rendererSettings: { preserveAspectRatio: "xMidYMid slice" },
}),
[isDarkMode]
return (
<Lottie
animationData={isDarkMode ? animationDataDark : animationDataLight}
loop
autoplay
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
style={{ width: size ?? "100%", height: size ?? "100%" }}
/>
);
return <Lottie options={options} height={size} width={size} />;
}

View file

@ -0,0 +1,16 @@
import Lottie from "lottie-react";
import animationData from "src/assets/lotties/success-check.json";
export default function LottieSuccess({ size = 288 }: { size?: number }) {
return (
<div className="[&_path[fill='rgb(75,177,0)']]:fill-positive-foreground [&_path[stroke='rgb(75,177,0)']]:stroke-positive-foreground [&_path[stroke='rgb(255,255,255)']]:stroke-card">
<Lottie
animationData={animationData}
loop={false}
autoplay
rendererSettings={{ preserveAspectRatio: "xMidYMid meet" }}
style={{ width: size, height: size }}
/>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { LinkIcon } from "lucide-react";
import { BitcoinIcon } from "lucide-react";
import EmptyState from "src/components/EmptyState";
import Loading from "src/components/Loading";
import OnchainTransactionItem from "src/components/OnchainTransactionItem";
@ -18,12 +18,9 @@ export function OnchainTransactionsList() {
return (
<div className="flex w-full flex-1 flex-col">
<EmptyState
icon={LinkIcon}
icon={BitcoinIcon}
title="No on-chain transactions yet"
description="Your most recent incoming and outgoing on-chain transactions will show up here."
buttonText="Receive to On-chain Balance"
buttonLink="/wallet/receive?type=onchain"
showBorder={false}
description="Your bitcoin transactions will appear here as you start using your wallet."
/>
</div>
);

View file

@ -3,7 +3,6 @@ import { CopyIcon, ExternalLinkIcon } from "lucide-react";
import React from "react";
import { FixedFloatButton } from "src/components/FixedFloatButton";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import { LightningIcon } from "src/components/icons/Lightning";
import Loading from "src/components/Loading";
import QRCode from "src/components/QRCode";
import { Button } from "src/components/ui/button";
@ -28,16 +27,13 @@ export function PayLightningInvoice({ invoice }: PayLightningInvoiceProps) {
};
return (
<div className="w-96 flex flex-col gap-6 p-6 items-center justify-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<div className="flex w-full flex-col items-center justify-center gap-6">
<div className="flex items-center justify-center gap-2 font-semibold leading-none">
<Loading variant="loader" />
<p>Waiting for lightning payment...</p>
<p>Waiting for Payment...</p>
</div>
<div className="w-full relative flex items-center justify-center">
<QRCode value={invoice} className="w-full" />
<div className="bg-white absolute rounded-full p-1">
<LightningIcon className="w-12 h-12" />
</div>
<div className="relative flex w-full items-center justify-center">
<QRCode value={invoice} className="w-full" paymentType="lightning" />
</div>
<div>
<p className="text-lg font-semibold">
@ -50,23 +46,19 @@ export function PayLightningInvoice({ invoice }: PayLightningInvoiceProps) {
}).format(fiatAmount)}
</p>
</div>
<div className="flex flex-col gap-2 w-full">
<Button
onClick={copy}
variant="outline"
className="flex-1 flex gap-2 items-center justify-center"
>
<div className="flex w-full flex-col gap-3">
<Button onClick={copy} variant="secondary" className="w-full">
<CopyIcon />
Copy Invoice
</Button>
<FixedFloatButton
to="BTCLN"
address={invoice}
className="flex-1 flex gap-2 items-center justify-center"
variant="secondary"
className="w-full"
variant="outline"
>
Pay with other Cryptocurrency
<ExternalLinkIcon className="size-4" />
Pay with Crypto
</FixedFloatButton>
</div>
</div>

View file

@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
import { LoadingButton } from "src/components/ui/custom/loading-button";
import { useChannels } from "src/hooks/useChannels";
import { request } from "src/utils/request";
import { sendEvent } from "src/utils/sendEvent";
export function PaymentFailedAlert({
invoice,
@ -19,25 +19,12 @@ export function PaymentFailedAlert({
async function sendDetailsToAlby() {
setSendingDetailsToAlby(true);
try {
await request(`/api/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
event: "payment_failed_details",
properties: {
invoice,
errorMessage,
channels,
},
}),
});
toast("Thanks for improving Alby Hub.");
} catch (error) {
console.error(error);
}
await sendEvent("payment_failed_details", {
invoice,
errorMessage,
channels,
});
toast("Thanks for improving Alby Hub.");
setSendingDetailsToAlby(false);
}

View file

@ -1,38 +1,116 @@
import ReactQRCode from "react-qr-code";
import { type ReactNode, useEffect, useMemo, useRef } from "react";
import QRCodeStyling, { type Options } from "qr-code-styling";
import { BitcoinPaymentIcon } from "src/components/icons/BitcoinPayment";
import { LightningIcon } from "src/components/icons/Lightning";
import { cn } from "src/lib/utils";
export type Props = {
value: string;
size?: number;
className?: string;
showAvatar?: boolean;
frameType?: "lightning" | "onchain";
paymentType?: "lightning" | "onchain";
centerContent?: ReactNode;
// set the level to Q if there are overlays
// Q will improve error correction (so we can add overlays covering up to 25% of the QR)
// at the price of decreased information density (meaning the QR codes "pixels" have to be
// smaller to encode the same information).
// While that isn't that much of a problem for lightning addresses (because they are usually quite short),
// for invoices that contain larger amount of data those QR codes can get "harder" to read.
// (meaning you have to aim your phone very precisely and have to wait longer for the reader
// to recognize the QR code)
// Use Q when an external overlay covers part of the QR code.
level?: "Q" | undefined;
};
function QRCode({ value, size, level, className }: Props) {
// Do not use dark mode: some apps do not handle it well (e.g. Phoenix)
// const { isDarkMode } = useTheme();
const fgColor = "#242424"; // isDarkMode ? "#FFFFFF" : "#242424";
const bgColor = "#FFFFFF"; // isDarkMode ? "#242424" : "#FFFFFF";
function QRCode({
value,
size = 256,
level,
className,
showAvatar = false,
frameType,
paymentType,
centerContent,
}: Props) {
const resolvedFrameType = paymentType ?? frameType;
const hasCenterContent = Boolean(centerContent);
const containerRef = useRef<HTMLDivElement>(null);
const options = useMemo<Options>(
() => ({
type: "svg",
width: size,
height: size,
data: value,
image: showAvatar ? "/icon-lightmode.svg" : undefined,
margin: 0,
qrOptions: {
errorCorrectionLevel:
level ?? (showAvatar || paymentType || hasCenterContent ? "Q" : "M"),
},
imageOptions: {
crossOrigin: "anonymous",
hideBackgroundDots: true,
imageSize: 0.15,
margin: 4,
},
dotsOptions: {
color: "var(--qr-foreground)",
type: "dots",
roundSize: false,
},
cornersSquareOptions: {
color: "var(--qr-foreground)",
type: "extra-rounded",
},
cornersDotOptions: {
color: "var(--qr-foreground)",
type: "dot",
},
backgroundOptions: {
color: "var(--qr-background)",
},
}),
[hasCenterContent, level, paymentType, showAvatar, size, value]
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const qrCode = new QRCodeStyling(options);
qrCode.append(container);
return () => {
container.replaceChildren();
};
}, [options]);
return (
<div className="bg-white p-2 rounded-md">
<ReactQRCode
value={value}
size={size}
fgColor={fgColor}
bgColor={bgColor}
className={cn("rounded", className)}
level={level}
/>
<div
className={cn(
"relative w-full rounded-[28px] p-2",
resolvedFrameType === "lightning"
? "bg-payment-lightning"
: resolvedFrameType === "onchain"
? "bg-payment-onchain"
: "bg-primary",
className
)}
style={{ maxWidth: size }}
>
<div className="rounded-3xl bg-qr-background p-4">
<div
ref={containerRef}
className="aspect-square w-full overflow-hidden [&_svg]:size-full"
/>
</div>
{(paymentType || centerContent) && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-qr-background p-1 text-qr-background">
{centerContent ??
(paymentType === "lightning" ? (
<LightningIcon className="size-12" />
) : (
<BitcoinPaymentIcon className="size-12" />
))}
</div>
)}
</div>
);
}

View file

@ -134,7 +134,7 @@ export function RebalanceChannelDialogContent({
}}
/>
<p className="mt-2 text-xs text-muted-foreground">
Fee: 0.3%
Fee: 0.5%
{!!amountSat && (
<>
&nbsp;(

View file

@ -1,10 +1,22 @@
import { CopyIcon, LinkIcon, ReceiptTextIcon, ZapIcon } from "lucide-react";
import {
ChevronRightIcon,
CopyIcon,
LinkIcon,
ReceiptTextIcon,
ZapIcon,
} from "lucide-react";
import { Link } from "react-router";
import FirstChannelJitAlert from "src/components/FirstChannelJitAlert";
import Loading from "src/components/Loading";
import QRCode from "src/components/QRCode";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "src/components/ui/accordion";
import { Button } from "src/components/ui/button";
import { Card, CardContent, CardFooter } from "src/components/ui/card";
import { LinkButton } from "src/components/ui/custom/link-button";
import { Separator } from "src/components/ui/separator";
import { Card, CardContent } from "src/components/ui/card";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useInfo } from "src/hooks/useInfo";
import { copyToClipboard } from "src/lib/clipboard";
@ -18,43 +30,87 @@ export function ReceiveToLightning() {
}
return (
<Card>
<CardContent className="flex flex-col items-center gap-6">
<QRCode value={me.lightning_address} className="w-full h-auto" />
<p className="text-center font-medium text-lg break-all">
{me.lightning_address}
</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 pt-2">
<Button
variant="secondary"
onClick={() => {
copyToClipboard(me.lightning_address);
}}
className="w-full"
>
<CopyIcon className="size-4" /> Copy Lightning Address
</Button>
<Separator className="my-4" />
<LinkButton to="invoice" variant="outline" className="w-full">
<ZapIcon className="w-4 h-4 mr-2" />
Create Invoice
</LinkButton>
{info.backendType === "LDK" && (
<LinkButton
to="/wallet/receive/offer"
variant="outline"
className="w-full"
>
<ReceiptTextIcon className="h-4 w-4 mr-2" />
Lightning Offer
</LinkButton>
)}
<LinkButton to="onchain" variant="outline" className="w-full">
<LinkIcon className="w-4 h-4 mr-2" />
Receive from On-chain / Other Cryptocurrency
</LinkButton>
</CardFooter>
</Card>
<div className="flex flex-col gap-5">
<FirstChannelJitAlert />
<Card>
<CardContent className="flex flex-col items-center gap-6">
<QRCode
value={me.lightning_address}
className="h-auto w-full"
frameType="lightning"
/>
<div className="flex max-w-full items-center justify-center gap-1">
<p className="min-w-0 text-center font-medium text-lg break-all">
{me.lightning_address}
</p>
<Button
variant="ghost"
size="icon"
aria-label="Copy Lightning Address"
className="shrink-0"
onClick={() => {
copyToClipboard(me.lightning_address);
}}
>
<CopyIcon className="size-4" />
</Button>
</div>
</CardContent>
</Card>
<Card className="py-2">
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="more-options">
<AccordionTrigger>Other ways to receive</AccordionTrigger>
<AccordionContent className="flex flex-col divide-y pb-1">
<Link
to="/wallet/receive/invoice"
className="group flex items-center gap-3 py-3"
>
<ZapIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm font-medium">Create Invoice</p>
<p className="text-xs text-muted-foreground">
Request a specific amount with a one-time invoice
</p>
</div>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
{info.supportsBolt12 && (
<Link
to="/wallet/receive/offer"
className="group flex items-center gap-3 py-3"
>
<ReceiptTextIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm font-medium">Lightning Offer</p>
<p className="text-xs text-muted-foreground">
Share a reusable payment code that never expires
</p>
</div>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
)}
<Link
to="/wallet/receive/onchain"
className="group flex items-center gap-3 py-3"
>
<LinkIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm font-medium">
On-chain or Other Cryptocurrency
</p>
<p className="text-xs text-muted-foreground">
Swap funds from on-chain bitcoin or other cryptocurrencies
</p>
</div>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
);
}

View file

@ -5,13 +5,13 @@ import {
HandCoinsIcon,
RefreshCwIcon,
} from "lucide-react";
import TickSVG from "public/images/illustrations/tick.svg";
import { useEffect, useRef, useState } from "react";
import { FixedFloatButton } from "src/components/FixedFloatButton";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import Loading from "src/components/Loading";
import LottieLoading from "src/components/LottieLoading";
import LottieSuccess from "src/components/LottieSuccess";
import OnchainAddressDisplay from "src/components/OnchainAddressDisplay";
import QRCode from "src/components/QRCode";
import { Button } from "src/components/ui/button";
@ -120,13 +120,13 @@ export function ReceiveToOnchain() {
target="_blank"
className="flex justify-center"
>
<QRCode value={onchainAddress} />
<QRCode value={onchainAddress} paymentType="onchain" />
</a>
<div className="flex flex-wrap max-w-64 gap-2 items-center justify-center">
<OnchainAddressDisplay address={onchainAddress} />
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2 pt-2">
<CardFooter className="flex flex-col gap-3 pt-2">
<Button
className="w-full"
onClick={() => {
@ -150,10 +150,10 @@ export function ReceiveToOnchain() {
to="BTC"
address={onchainAddress}
className="w-full"
variant="secondary"
variant="outline"
>
<ExternalLinkIcon className="size-4" />
Top up using other Cryptocurrency
Top Up with Crypto
</FixedFloatButton>
</CardFooter>
</Card>
@ -174,8 +174,7 @@ function DepositPending({
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center justify-center gap-2">
<Loading className="w-4 h-4" />
<CardTitle className="text-center">
Waiting for On-chain Confirmation...
</CardTitle>
</CardHeader>
@ -190,13 +189,13 @@ function DepositPending({
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-2 pt-2">
<CardFooter className="flex flex-col gap-3 pt-2">
<ExternalLinkButton
to={`${info?.mempoolUrl}/tx/${txId}`}
variant="outline"
className="w-full"
>
<ExternalLinkIcon className="w-4 h-4 mr-2" />
<ExternalLinkIcon className="size-4" />
View on Mempool
</ExternalLinkButton>
</CardFooter>
@ -221,7 +220,7 @@ function DepositSuccess({
<CardTitle className="text-center">Transaction Received!</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-6">
<img src={TickSVG} className="w-48" />
<LottieSuccess />
<div className="flex flex-col gap-1 items-center">
<p className="text-2xl font-medium slashed-zero">
<FormattedBitcoinAmount amountMsat={amountSat * 1000} />
@ -229,13 +228,13 @@ function DepositSuccess({
<FormattedFiatAmount amountSat={amountSat} className="text-xl" />
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2 pt-2">
<CardFooter className="flex flex-col gap-3 pt-2">
<ExternalLinkButton
to={`${info?.mempoolUrl}/tx/${txId}`}
variant="outline"
className="w-full"
>
<ExternalLinkIcon className="w-4 h-4 mr-2" />
<ExternalLinkIcon className="size-4" />
View on Mempool
</ExternalLinkButton>
<Button
@ -244,11 +243,11 @@ function DepositSuccess({
className="w-full"
onClick={onReceiveAnother}
>
<HandCoinsIcon className="w-4 h-4 mr-2" />
<HandCoinsIcon className="size-4" />
Receive Another Payment
</Button>
<LinkButton to="/wallet" variant="link" className="w-full">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
<ArrowLeftIcon className="size-4" />
Back to Wallet
</LinkButton>
</CardFooter>

View file

@ -19,7 +19,12 @@ import {
SheetTitle,
} from "src/components/ui/sheet";
import { cn } from "src/lib/utils";
import { Scope, WalletCapabilities, scopeDescriptions } from "src/types";
import {
READ_ONLY_SCOPES,
Scope,
WalletCapabilities,
scopeDescriptions,
} from "src/types";
const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const;
type ScopeGroup = (typeof scopeGroups)[number];
@ -65,17 +70,8 @@ const Scopes: React.FC<ScopesProps> = ({
}, [capabilities.scopes]);
const readOnlyScopes: Scope[] = React.useMemo(() => {
const readOnlyScopes: Scope[] = [
"get_balance",
"get_info",
"make_invoice",
"lookup_invoice",
"list_transactions",
"notifications",
];
return capabilities.scopes.filter((scope) =>
readOnlyScopes.includes(scope)
READ_ONLY_SCOPES.includes(scope)
);
}, [capabilities.scopes]);

View file

@ -277,10 +277,10 @@ function TransactionItem({ tx, transactionListKey }: Props) {
<TransactionDetailRow label="Date & Time">
{updatedAt.format("D MMMM YYYY, HH:mm")}
</TransactionDetailRow>
{tx.state != "failed" && type == "outgoing" && (
{tx.state != "failed" && tx.feesPaidMsat > 0 && (
<TransactionDetailRow label="Fee">
<FormattedBitcoinAmount amountMsat={tx.feesPaidMsat} />
{tx.feesPaidMsat > 0 && (
{type == "outgoing" && (
<>
&nbsp;(
{((tx.feesPaidMsat / tx.amountMsat) * 100).toFixed(2)}%)

View file

@ -0,0 +1,140 @@
import React from "react";
import { Button } from "src/components/ui/button";
import { Checkbox } from "src/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "src/components/ui/dialog";
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { ToggleGroup, ToggleGroupItem } from "src/components/ui/toggle-group";
import {
defaultTransactionFilters,
type TransactionFilters,
} from "src/hooks/useTransactions";
const TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: "All", value: "all" },
{ label: "Sent", value: "outgoing" },
{ label: "Received", value: "incoming" },
];
type TransactionsFilterDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: TransactionFilters;
onFiltersChange: (filters: TransactionFilters) => void;
};
export function TransactionsFilterDialog({
open,
onOpenChange,
filters,
onFiltersChange,
}: TransactionsFilterDialogProps) {
const [searchTerm, setSearchTerm] = React.useState("");
const [type, setType] = React.useState("all");
const [minAmountSat, setMinAmountSat] = React.useState("");
const [hideFailed, setHideFailed] = React.useState(false);
React.useEffect(() => {
if (open) {
setSearchTerm(filters.searchTerm ?? "");
setType(filters.type ?? "all");
setMinAmountSat(filters.minAmountSat ? String(filters.minAmountSat) : "");
setHideFailed(!!filters.hideFailed);
}
}, [open, filters]);
function onSubmit(e: React.FormEvent) {
e.preventDefault();
const parsedMinAmountSat = Number(minAmountSat);
onFiltersChange({
searchTerm: searchTerm.trim() || undefined,
type: type === "incoming" || type === "outgoing" ? type : undefined,
minAmountSat:
Number.isSafeInteger(parsedMinAmountSat) && parsedMinAmountSat > 0
? parsedMinAmountSat
: undefined,
hideFailed,
});
onOpenChange(false);
}
function onReset() {
onFiltersChange({ ...defaultTransactionFilters });
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={onSubmit}>
<DialogHeader>
<DialogTitle>Filter Transactions</DialogTitle>
<DialogDescription>
Choose which payments appear in your transaction list.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 mt-5">
<Label htmlFor="searchTerm">Search</Label>
<Input
autoFocus
id="searchTerm"
type="text"
placeholder="Description, payment hash, invoice or label"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid gap-2 mt-4">
<Label>Direction</Label>
<ToggleGroup
type="single"
variant="outline"
value={type}
onValueChange={(value) => value && setType(value)}
>
{TYPE_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="grid gap-2 mt-4">
<Label htmlFor="minAmountSat">Minimum amount (sats)</Label>
<Input
id="minAmountSat"
type="number"
min="1"
placeholder="Show all amounts"
value={minAmountSat}
onChange={(e) => setMinAmountSat(e.target.value.trim())}
/>
</div>
<div className="flex items-center mt-4">
<Checkbox
id="hideFailed"
checked={hideFailed}
onCheckedChange={(checked) => setHideFailed(checked === true)}
/>
<Label htmlFor="hideFailed" className="ml-2 cursor-pointer">
Hide failed payments
</Label>
</div>
<DialogFooter className="mt-5">
<Button type="button" variant="secondary" onClick={onReset}>
Reset
</Button>
<Button type="submit">Apply Filters</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -1,36 +1,63 @@
import { DrumIcon } from "lucide-react";
import { LucideIcon, ZapIcon } from "lucide-react";
import { useRef, useState } from "react";
import { CustomPagination } from "src/components/CustomPagination";
import EmptyState from "src/components/EmptyState";
import Loading from "src/components/Loading";
import TransactionItem from "src/components/TransactionItem";
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
import { getTransactionsUrl, useTransactions } from "src/hooks/useTransactions";
import {
getTransactionsUrl,
hasActiveTransactionFilters,
useTransactions,
} from "src/hooks/useTransactions";
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
type TransactionsListProps = {
appId?: number;
showReceiveButton?: boolean;
emptyIcon?: LucideIcon;
emptyTitle?: string;
emptyDescription?: string;
emptyVariant?: "dashed" | "muted" | "none";
};
function TransactionsList({
appId,
showReceiveButton = true,
emptyIcon = ZapIcon,
emptyTitle = "No lightning payments yet",
emptyDescription = "Your payments will appear here as you start using your wallet.",
emptyVariant,
}: TransactionsListProps) {
const [page, setPage] = useState(1);
const { filters } = useTransactionFiltersStore();
// Reset pagination during render when the filters or app change, so no
// request is made for a page that may not exist under the new list.
const [prevListIdentity, setPrevListIdentity] = useState({ appId, filters });
if (
prevListIdentity.appId !== appId ||
prevListIdentity.filters !== filters
) {
setPrevListIdentity({ appId, filters });
setPage(1);
}
const transactionListRef = useRef<HTMLDivElement>(null);
const transactionListKey = getTransactionsUrl(
appId,
LIST_TRANSACTIONS_LIMIT,
page
page,
filters
);
const { data: transactionData, isLoading } = useTransactions(
appId,
false,
LIST_TRANSACTIONS_LIMIT,
page
page,
filters
);
const transactions = transactionData?.transactions || [];
const totalCount = transactionData?.totalCount || 0;
const hasActiveFilters = hasActiveTransactionFilters(filters);
const handlePageChange = (page: number) => {
setPage(page);
@ -48,13 +75,14 @@ function TransactionsList({
<div ref={transactionListRef} className="flex flex-col flex-1">
{!transactions.length ? (
<EmptyState
icon={DrumIcon}
title="No transactions yet"
description="Your most recent incoming and outgoing payments will show up here."
buttonText="Receive Your First Payment"
buttonLink="/wallet/receive"
showButton={showReceiveButton}
showBorder={false}
icon={emptyIcon}
title={hasActiveFilters ? "No matching payments" : emptyTitle}
description={
hasActiveFilters
? "Try changing your filters to see more payments."
: emptyDescription
}
variant={emptyVariant}
/>
) : (
<>

View file

@ -1,27 +1,46 @@
import { DownloadIcon, EllipsisVerticalIcon } from "lucide-react";
import { DownloadIcon, EllipsisVerticalIcon, FunnelIcon } from "lucide-react";
import { useState } from "react";
import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog";
import { Button } from "src/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu";
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
import { handleExportTransactions } from "./transactions-utils";
export const TransactionsListMenu = ({ appId }: { appId?: number }) => {
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const { filters, setFilters } = useTransactionFiltersStore();
return (
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setFilterDialogOpen(true)}>
<FunnelIcon className="h-4 w-4" />
Filter Transactions
</DropdownMenuItem>
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TransactionsFilterDialog
open={filterDialogOpen}
onOpenChange={setFilterDialogOpen}
filters={filters}
onFiltersChange={setFilters}
/>
</>
);
};

View file

@ -4,9 +4,12 @@ import {
CreditCardIcon,
DownloadIcon,
EllipsisVerticalIcon,
FunnelIcon,
} from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import ExternalLink from "src/components/ExternalLink";
import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog";
import { Button } from "src/components/ui/button";
import {
DropdownMenu,
@ -16,55 +19,84 @@ import {
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu";
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
import { handleExportTransactions } from "./transactions-utils";
export function WalletActionsMenu({
hasChannelManagement,
isOnchain,
}: {
hasChannelManagement: boolean;
isOnchain: boolean;
}) {
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const { filters, setFilters } = useTransactionFiltersStore();
return (
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<div className="sm:hidden">
{hasChannelManagement && (
<>
<DropdownMenu>
<Button
asChild
size="icon"
variant="ghost"
className={isOnchain ? "sm:hidden" : undefined}
>
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<div className="sm:hidden">
{hasChannelManagement && (
<DropdownMenuItem asChild>
<Link to="/wallet/swap" className="w-full cursor-pointer">
<ArrowDownUpIcon className="h-4 w-4" />
Swap
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/wallet/swap" className="w-full cursor-pointer">
<ArrowDownUpIcon className="h-4 w-4" />
Swap
<Link
to="/internal-apps/zapplanner"
className="w-full cursor-pointer"
>
<CalendarSyncIcon className="h-4 w-4" />
Recurring
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ExternalLink
to="https://www.getalby.com/topup"
className="w-full cursor-pointer"
>
<CreditCardIcon className="h-4 w-4" />
Buy
</ExternalLink>
</DropdownMenuItem>
{!isOnchain && <DropdownMenuSeparator />}
</div>
{!isOnchain && (
<>
<DropdownMenuItem onClick={() => setFilterDialogOpen(true)}>
<FunnelIcon className="h-4 w-4" />
Filter Transactions
</DropdownMenuItem>
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>
<Link
to="/internal-apps/zapplanner"
className="w-full cursor-pointer"
>
<CalendarSyncIcon className="h-4 w-4" />
Recurring
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ExternalLink
to="https://www.getalby.com/topup"
className="w-full cursor-pointer"
>
<CreditCardIcon className="h-4 w-4" />
Buy
</ExternalLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
</div>
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
{!isOnchain && (
<TransactionsFilterDialog
open={filterDialogOpen}
onOpenChange={setFilterDialogOpen}
filters={filters}
onFiltersChange={setFilters}
/>
)}
</>
);
}

View file

@ -49,6 +49,7 @@ export function ChannelWaitingForConfirmations({
icon={FootprintsIcon}
title="Browse While You Wait"
description="Feel free to leave this page or browse around Alby Hub! We'll send you an email as soon as your channel is active."
variant="dashed"
buttonText="Explore Apps"
buttonLink="/apps?tab=app-store"
/>

View file

@ -24,36 +24,27 @@ export function AppStoreDetailHeader({
<>
<AppHeader
pageTitle={appStoreApp.title}
title={
<>
<div className="flex flex-row items-center">
<img
src={appStoreApp.logo}
className="w-14 h-14 rounded-lg mr-4"
/>
<div className="flex flex-col">
<div className="flex items-center gap-2">
{appStoreApp.title}
{!!connectedApps.length && (
<Badge
variant="positive"
className="flex items-center gap-1"
>
<CheckCircleIcon className="w-3 h-3" />{" "}
{connectedApps.length > 1
? `${connectedApps.length} Connections`
: "Connected"}
</Badge>
)}
</div>
<div className="text-sm font-normal text-muted-foreground">
{appStoreApp.description}
</div>
</div>
</div>
</>
icon={
<img
src={appStoreApp.logo}
alt="logo"
className="inline rounded-lg w-14 h-14"
/>
}
description=""
title={
<div className="flex items-center gap-2">
{appStoreApp.title}
{!!connectedApps.length && (
<Badge variant="positive" className="flex items-center gap-1">
<CheckCircleIcon className="w-3 h-3" />{" "}
{connectedApps.length > 1
? `${connectedApps.length} Connections`
: "Connected"}
</Badge>
)}
</div>
}
description={appStoreApp.description}
contentRight={
contentRight !== undefined ? (
contentRight

View file

@ -1,3 +1,4 @@
import { ArrowDownUpIcon } from "lucide-react";
import TransactionsList from "src/components/TransactionsList";
import { TransactionsListMenu } from "src/components/TransactionsListMenu";
import {
@ -15,7 +16,13 @@ export function AppTransactionList({ appId }: { appId: number }) {
<TransactionsListMenu appId={appId} />
</CardHeader>
<CardContent>
<TransactionsList appId={appId} showReceiveButton={false} />
<TransactionsList
appId={appId}
emptyIcon={ArrowDownUpIcon}
emptyTitle="No transactions yet"
emptyDescription="Payments made through this app will appear here."
emptyVariant="none"
/>
</CardContent>
</Card>
);

View file

@ -0,0 +1,33 @@
import { useLocation } from "react-router";
import ExternalLink from "src/components/ExternalLink";
// The card topup app supports configuration presets selected via a `provider`
// query param (e.g. linked from the Cards page). Read it straight from the URL
// so the link opens card.albylabs.com pre-configured for the chosen provider.
export function BitcoinCardTopupInstallGuide() {
const provider = new URLSearchParams(useLocation().search).get("provider");
const url = provider
? `https://card.albylabs.com?provider=${encodeURIComponent(provider)}`
: "https://card.albylabs.com";
return (
<div>
<ul className="list-inside list-decimal text-muted-foreground">
<li>
Open{" "}
<ExternalLink to={url} className="underline">
card.albylabs.com
</ExternalLink>{" "}
on the device you'll top up from.
</li>
<li>
<span className="font-medium text-foreground">
Add it to your home screen
</span>{" "}
(or bookmark it) so you can reopen it later.
</li>
<li>Enter your card's deposit details to set it up.</li>
</ul>
</div>
);
}

View file

@ -88,6 +88,7 @@ function ConnectedApps() {
icon={CableIcon}
title="Connect Your First App"
description="Connect your app of choice, fine-tune permissions and enjoy a seamless and secure wallet experience."
variant="dashed"
buttonText="See Recommended Apps"
buttonLink="/apps?tab=app-store"
/>

View file

@ -6,6 +6,7 @@ import albyGo from "src/assets/suggested-apps/alby-go.png";
import albySandbox from "src/assets/suggested-apps/alby-sandbox.png";
import albyCli from "src/assets/suggested-apps/alby.png";
import amethyst from "src/assets/suggested-apps/amethyst.png";
import bitcoinCardTopup from "src/assets/suggested-apps/bitcoin-card-topup.png";
import bitrefill from "src/assets/suggested-apps/bitrefill.png";
import bitrequest from "src/assets/suggested-apps/bitrequest.png";
import bringin from "src/assets/suggested-apps/bringin.png";
@ -19,7 +20,6 @@ import fountain from "src/assets/suggested-apps/fountain.png";
import hablanews from "src/assets/suggested-apps/habla-news.png";
import iris from "src/assets/suggested-apps/iris.png";
import jumble from "src/assets/suggested-apps/jumble.png";
import lendaswap from "src/assets/suggested-apps/lendaswap.png";
import lightningMessageboard from "src/assets/suggested-apps/lightning-messageboard.png";
import lnbits from "src/assets/suggested-apps/lnbits.png";
import lnvps from "src/assets/suggested-apps/lnvps.png";
@ -30,14 +30,13 @@ import nostrcheckserver from "src/assets/suggested-apps/nostrcheck-server.png";
import nostrudel from "src/assets/suggested-apps/nostrudel.png";
import nostter from "src/assets/suggested-apps/nostter.png";
import nostur from "src/assets/suggested-apps/nostur.png";
import paperScissorsHodl from "src/assets/suggested-apps/paper-scissors-hodl.png";
import payperq from "src/assets/suggested-apps/payperq.png";
import primal from "src/assets/suggested-apps/primal.png";
import pullthatupjamie from "src/assets/suggested-apps/pullthatupjamie.png";
import runstr from "src/assets/suggested-apps/runstr.png";
import sats4ai from "src/assets/suggested-apps/sats4ai.png";
import satsorter from "src/assets/suggested-apps/sat-sorter.png";
import satoshisauctionhouse from "src/assets/suggested-apps/satoshis-auction-house.png";
import satoraLogo from "src/assets/suggested-apps/satora.png";
import sats4ai from "src/assets/suggested-apps/sats4ai.png";
import simpleboost from "src/assets/suggested-apps/simple-boost.png";
import snort from "src/assets/suggested-apps/snort.png";
import stackernews from "src/assets/suggested-apps/stacker-news.png";
@ -54,12 +53,14 @@ import zapplepay from "src/assets/suggested-apps/zapple-pay.png";
import zappybird from "src/assets/suggested-apps/zappy-bird.png";
import zapstore from "src/assets/suggested-apps/zapstore.png";
import zeus from "src/assets/suggested-apps/zeus.png";
import { BitcoinCardTopupInstallGuide } from "src/components/connections/BitcoinCardTopupInstallGuide";
import ExternalLink from "src/components/ExternalLink";
import { App } from "src/types";
export type AppStoreApp = {
id: string;
title: string;
legacyTitles?: string[];
description: string;
extendedDescription: string;
@ -83,6 +84,9 @@ export type AppStoreApp = {
hideConnectionQr?: boolean;
internal?: boolean;
superuser?: boolean;
// Receive-only apps (e.g. merchant payment receivers) default to read-only
// permissions in the new connection flow.
readonly?: boolean;
addedDate?: string;
};
@ -219,6 +223,33 @@ export const appStoreApps: AppStoreApp[] = (
categories: ["audio"],
addedDate: "2026-03-12",
},
{
id: "bitcoin-card-topup",
title: "Bitcoin Card Topup",
description: "Top up any crypto debit card instantly with bitcoin",
logo: bitcoinCardTopup,
categories: ["payment-tools"],
extendedDescription:
"A generic top-up app that swaps Lightning sats to a stablecoin and sends them to your card's deposit address. Works with RedotPay, Freedomia, Nexo, Bybit, and any other card that accepts on-chain crypto deposits.",
webLink: "https://card.albylabs.com",
installGuide: <BitcoinCardTopupInstallGuide />,
finalizeGuide: (
<>
<div>
<ul className="list-inside list-decimal text-muted-foreground">
<li>Copy the connection secret below.</li>
<li>
In the topup app, tap{" "}
<span className="font-medium text-foreground">
Connect Wallet
</span>{" "}
and paste the connection secret.
</li>
</ul>
</div>
</>
),
},
{
id: "2fiat",
title: "2fiat Top up",
@ -768,6 +799,7 @@ export const appStoreApps: AppStoreApp[] = (
},
{
id: "sat-sorter",
readonly: true,
title: "Sat Sorter",
description: "A Bitcoin Budgeting App",
webLink: "https://satsorter.com",
@ -867,6 +899,7 @@ export const appStoreApps: AppStoreApp[] = (
},
{
id: "bitrequest",
readonly: true,
title: "Bitrequest",
description: "Non-custodial payment requests",
webLink: "https://www.bitrequest.io",
@ -889,17 +922,6 @@ export const appStoreApps: AppStoreApp[] = (
</ExternalLink>{" "}
in your browser, or download the app on iOS or Android
</p>
<p className="text-muted-foreground mt-4">
In the next step, set wallet permissions to{" "}
<span className="font-medium text-foreground">Custom</span> and
enable:
</p>
<ul className="list-inside list-disc text-muted-foreground mt-1">
<li>Read your node info</li>
<li>Create invoices</li>
<li>Lookup status of invoices</li>
<li>Read transaction history</li>
</ul>
</div>
</>
),
@ -939,6 +961,7 @@ export const appStoreApps: AppStoreApp[] = (
},
{
id: "btcpay",
readonly: true,
title: "BTCPay Server",
description: "Bitcoin payment processor",
webLink: "https://btcpayserver.org/",
@ -1245,8 +1268,7 @@ export const appStoreApps: AppStoreApp[] = (
title: "wavecard® by wave.space",
description:
"Spend Bitcoin from your AlbyHub at 150M+ merchants worldwide",
webLink:
"https://app.wave.space/spend/?utm_source=albyhub&affiliate=AlbyHub",
webLink: "https://app.wave.space/?utm_source=albyhub&affiliate=AlbyHub",
logo: wavespace,
extendedDescription:
"The world's first Bitcoin VISA Debit Card that allows you to spend BTC globally, anywhere VISA is accepted straight from the safety of your own NWC-enabled wallet. ✨ EXCLUSIVE ALBYHUB SPECIAL🐝 → Get 21% cashback on your wavecard transactions (up to 10,000 sats) using code »AlbyHub«",
@ -1258,10 +1280,10 @@ export const appStoreApps: AppStoreApp[] = (
<li>
Open{" "}
<ExternalLink
to="https://app.wave.space/spend/?utm_source=albyhub&affiliate=AlbyHub"
to="https://app.wave.space/?utm_source=albyhub&affiliate=AlbyHub"
className="font-medium text-foreground underline"
>
wave.space/spend
wave.space
</ExternalLink>{" "}
in your browser and{" "}
<span className="font-medium text-foreground">
@ -1427,6 +1449,7 @@ export const appStoreApps: AppStoreApp[] = (
},
{
id: "clams",
readonly: true,
title: "Clams",
description: "Multi wallet accounting tool",
webLink: "https://clams.tech/",
@ -1463,6 +1486,7 @@ export const appStoreApps: AppStoreApp[] = (
},
{
id: "nostrcheck-server",
readonly: true,
title: "Nostrcheck Server",
description: "Sovereign Nostr services",
webLink: "https://github.com/quentintaranpino/nostrcheck-server",
@ -1770,6 +1794,7 @@ export const appStoreApps: AppStoreApp[] = (
},
{
id: "nakapay",
readonly: true,
title: "NakaPay",
description: "Non-custodial Lightning payments for businesses via NWC",
webLink: "https://www.nakapay.app",
@ -2042,47 +2067,6 @@ export const appStoreApps: AppStoreApp[] = (
),
categories: ["social-media"],
},
{
id: "paper-scissors-hodl",
title: "Paper Scissors HODL",
description: "Paper Scissors Rock with bitcoin at stake",
webLink: "https://paper-scissors-hodl.fly.dev",
logo: paperScissorsHodl,
extendedDescription:
"Uses your Hub to pay to play a round, and receive the reward if you win",
installGuide: (
<>
<p className="text-muted-foreground">
Open{" "}
<ExternalLink
to="https://paper-scissors-hodl.fly.dev/"
className="font-medium text-foreground underline"
>
Paper Scissors HODL
</ExternalLink>{" "}
in your browser
</p>
</>
),
finalizeGuide: (
<>
<div>
<h3 className="font-medium">In Paper Scissors HODL</h3>
<ul className="list-inside list-decimal text-muted-foreground">
<li>Start playing until the Bitcoin Connect screen pops up </li>
<li>
Choose{" "}
<span className="font-medium text-foreground">
Nostr Wallet Connect
</span>
</li>
<li>Paste the connection secret from Alby Hub</li>
</ul>
</div>
</>
),
categories: ["games"],
},
{
id: "pullthatupjamie-ai",
title: "Pull That Up Jamie!",
@ -2220,10 +2204,40 @@ export const appStoreApps: AppStoreApp[] = (
title: "Bitrefill",
description: "Live on bitcoin",
extendedDescription: "Buy gift cards and e-sims with no KYC",
internal: true,
webLink: "https://bitrefill.com",
logo: bitrefill,
categories: ["shopping"],
appleLink:
"https://apps.apple.com/us/app/bitrefill-esims-gift-cards/id1378102623",
playLink:
"https://play.google.com/store/apps/details?id=com.bitrefill.app&hl=en",
installGuide: (
<>
<p className="text-muted-foreground">
Open{" "}
<ExternalLink
to="https://bitrefill.com"
className="font-medium text-foreground underline"
>
Bitrefill
</ExternalLink>{" "}
in your browser, or download the app on iOS or Android
</p>
</>
),
finalizeGuide: (
<>
<div>
<h3 className="font-medium">In Bitrefill</h3>
<ul className="list-inside list-decimal text-muted-foreground">
<li>
Go to settings {"->"} wallets {"->"} lightning {"->"} payments.
</li>
<li>Paste the connection secret to connect Alby Hub</li>
</ul>
</div>
</>
),
},
{
id: "bringin",
@ -2319,49 +2333,9 @@ export const appStoreApps: AppStoreApp[] = (
),
categories: ["social-media"],
},
{
id: "satoshis-auction-house",
title: "Satoshi's Auction House",
description: "Bitcoin-powered auction platform",
webLink: "https://satoshisauction.house",
logo: satoshisauctionhouse,
extendedDescription:
"Buy and sell items through Bitcoin-powered auctions directly from your Hub",
installGuide: (
<>
<p className="text-muted-foreground">
Open{" "}
<ExternalLink
to="https://satoshisauction.house"
className="font-medium text-foreground underline"
>
Satoshi's Auction House
</ExternalLink>{" "}
in your browser
</p>
</>
),
finalizeGuide: (
<>
<div>
<h3 className="font-medium">In Satoshi's Auction House</h3>
<ul className="list-inside list-decimal text-muted-foreground">
<li>
Click on the Hamburger menu on the top right and click{" "}
<span className="font-medium text-foreground">Settings</span>
</li>
<li>
Paste the connection secret from Alby Hub into the receive-only
connection secret field
</li>
</ul>
</div>
</>
),
categories: ["shopping"],
},
{
id: "takemysats",
readonly: true,
title: "Take My Sats",
description: "Create your online store and accept Bitcoin payments",
webLink: "https://www.takemysats.com",
@ -2467,22 +2441,24 @@ export const appStoreApps: AppStoreApp[] = (
categories: ["misc"],
},
{
// Keep the legacy app store ID so existing LendaSwap connections still match this entry.
id: "lendaswap",
title: "LendaSwap",
title: "Satora",
legacyTitles: ["LendaSwap"],
description: "Self-custodial Bitcoin ↔ Stablecoin atomic swaps",
webLink: "https://lendaswap.com/?ref=lnds_e3f8dd88_f7db93dbf176",
logo: lendaswap,
webLink: "https://app.satora.io/?ref=lnds_e3f8dd88_f7db93dbf176",
logo: satoraLogo,
extendedDescription:
"Swap between Lightning Bitcoin and stablecoins (USDC, USDT) on Polygon, Arbitrum, and Ethereum. LendaSwap uses your Hub to pay swap invoices and generate receiving invoices — all self-custodial via HTLCs.",
"Swap between Lightning Bitcoin and stablecoins (USDC, USDT) on Polygon, Arbitrum, and Ethereum. Satora uses your Hub to pay swap invoices and generate receiving invoices — all self-custodial via HTLCs.",
installGuide: (
<>
<p className="text-muted-foreground">
Open{" "}
<ExternalLink
to="https://lendaswap.com/?ref=lnds_e3f8dd88_f7db93dbf176"
to="https://app.satora.io/?ref=lnds_e3f8dd88_f7db93dbf176"
className="font-medium text-foreground underline"
>
LendaSwap
Satora
</ExternalLink>{" "}
in your browser
</p>
@ -2491,7 +2467,7 @@ export const appStoreApps: AppStoreApp[] = (
finalizeGuide: (
<>
<div>
<h3 className="font-medium">In LendaSwap</h3>
<h3 className="font-medium">In Satora</h3>
<ul className="list-inside list-decimal text-muted-foreground">
<li>
Click the{" "}
@ -2533,6 +2509,7 @@ export const getAppStoreApp = (app: App) => {
return appStoreApps.find(
(suggestedApp) =>
suggestedApp.id === (app.metadata?.app_store_app_id ?? "") ||
app.name.includes(suggestedApp.title)
app.name.includes(suggestedApp.title) ||
suggestedApp.legacyTitles?.some((title) => app.name.includes(title))
);
};

View file

@ -28,7 +28,12 @@ function AppCard(app: AppStoreApp) {
/>
<div className="grow">
<CardTitle>{app.title}</CardTitle>
<CardDescription>{app.description}</CardDescription>
<CardDescription>
{app.description}
{app.legacyTitles?.length
? ` (Previously ${app.legacyTitles.join(", ")})`
: ""}
</CardDescription>
</div>
</div>
</CardContent>

View file

@ -1,51 +0,0 @@
import { ExternalLinkIcon } from "lucide-react";
import { AlbyHead } from "src/components/images/AlbyHead";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
import { useInfo } from "src/hooks/useInfo";
export function AlbyAccountWidget() {
const { data: info } = useInfo();
if (!info || !info.albyAccountConnected) {
return null;
}
return (
<Card>
<CardHeader>
<div className="flex flex-row items-center">
<div className="shrink-0">
<AlbyHead className="h-12 w-12 rounded-xl p-1 border" />
</div>
<div>
<CardTitle>
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4">
Alby Account
</div>
</CardTitle>
<CardDescription className="ml-4">
Get an Alby Account with a web wallet interface, lightning address
and other features.
</CardDescription>
</div>
</div>
</CardHeader>
<CardFooter className="justify-end px-6 pt-0">
<ExternalLinkButton
to="https://www.getalby.com/dashboard"
variant="outline"
>
Open Alby Account
<ExternalLinkIcon className="size-4" />
</ExternalLinkButton>
</CardFooter>
</Card>
);
}

View file

@ -1,50 +0,0 @@
import { ExternalLinkIcon } from "lucide-react";
import { AlbyHead } from "src/components/images/AlbyHead";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
export function AlbyExtensionWidget() {
/* eslint-disable @typescript-eslint/no-explicit-any */
const extensionInstalled = (window as any).alby !== undefined;
if (extensionInstalled) {
return null;
}
return (
<Card>
<CardHeader>
<div className="flex flex-row items-center">
<div className="shrink-0">
<AlbyHead className="w-12 h-12 rounded-xl p-1 border bg-[#FFDF6F]" />
</div>
<div>
<CardTitle>
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4">
Alby Browser Extension
</div>
</CardTitle>
<CardDescription className="ml-4">
Seamless bitcoin payments in your favorite internet browser.
</CardDescription>
</div>
</div>
</CardHeader>
<CardFooter className="justify-end px-6 pt-0">
<ExternalLinkButton
to="https://getalby.com/products/browser-extension"
variant="outline"
>
Install Alby Extension
<ExternalLinkIcon className="size-4" />
</ExternalLinkButton>
</CardFooter>
</Card>
);
}

View file

@ -1,42 +0,0 @@
import albyGo from "src/assets/suggested-apps/alby-go.png";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { LinkButton } from "src/components/ui/custom/link-button";
export function AlbyGoWidget() {
return (
<Card>
<CardHeader>
<div className="flex flex-row items-center">
<div className="shrink-0">
<img
src={albyGo}
alt="Alby Go"
className="h-12 w-12 rounded-xl border"
/>
</div>
<div>
<CardTitle>
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4">
Alby Go
</div>
</CardTitle>
<CardDescription className="ml-4">
The easiest Bitcoin mobile app that works great with Alby Hub.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="text-right">
<LinkButton to="/appstore/alby-go" variant="outline">
Open
</LinkButton>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,254 @@
import { XIcon } from "lucide-react";
import React from "react";
import { Link } from "react-router";
import useSWR from "swr";
import ExternalLink from "src/components/ExternalLink";
import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from "src/components/ui/dialog";
import { localStorageKeys } from "src/constants";
import { cn } from "src/lib/utils";
import { swrFetcher } from "src/utils/swr";
type StoryCta = {
label: string;
url: string;
openInNewTab: boolean;
};
type Story = {
id: string;
title: string;
avatar: string;
videoId?: string;
cta?: StoryCta;
};
type StoryApiResponse = {
id: number;
title: string;
avatar: string;
videoId?: string;
cta?: StoryCta;
};
function youTubeEmbedUrl(videoId: string) {
return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0`;
}
function loadViewedStoryIds(): Set<string> {
try {
const raw = localStorage.getItem(localStorageKeys.homeStoriesViewed);
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((id): id is string => typeof id === "string"));
} catch {
return new Set();
}
}
function persistViewedStoryIds(ids: Set<string>) {
try {
localStorage.setItem(
localStorageKeys.homeStoriesViewed,
JSON.stringify([...ids])
);
} catch {
// ignore quota / private mode
}
}
function StoryAvatar({ story, viewed }: { story: Story; viewed: boolean }) {
return (
<div
className={cn(
"relative box-border flex size-16 shrink-0 items-center justify-center rounded-full border-2 p-0.5",
viewed ? "border-accent" : "border-primary"
)}
>
<div className="relative flex size-full items-center justify-center overflow-hidden rounded-full bg-white dark:bg-muted">
<img
src={story.avatar}
alt={`${story.title} story`}
className="size-full rounded-full object-cover"
/>
</div>
</div>
);
}
export function StoriesWidget() {
const { data, error, isLoading } = useSWR<StoryApiResponse[]>(
"/api/alby/stories",
swrFetcher
);
const [activeStory, setActiveStory] = React.useState<Story | null>(null);
const [viewedIds, setViewedIds] =
React.useState<Set<string>>(loadViewedStoryIds);
const stories = React.useMemo<Story[]>(
() =>
error || !data
? []
: data.map((story) => ({
id: String(story.id),
title: story.title,
avatar: story.avatar,
videoId: story.videoId,
cta: story.cta,
})),
[data, error]
);
const markStoryViewed = React.useCallback((storyId: string) => {
setViewedIds((prev) => {
if (prev.has(storyId)) {
return prev;
}
const next = new Set(prev);
next.add(storyId);
persistViewedStoryIds(next);
return next;
});
}, []);
if (!isLoading && stories.length === 0) {
return null;
}
return (
<>
<Card className="overflow-hidden rounded-[14px] shadow-none">
<CardHeader className="px-6 pb-0">
<CardTitle className="text-base font-semibold">Stories</CardTitle>
</CardHeader>
<CardContent className="px-0 py-0">
<div className="flex gap-3 overflow-x-auto px-6 pb-1">
{isLoading && (
<span className="text-sm text-muted-foreground">
Loading stories...
</span>
)}
{!isLoading &&
stories.map((story) => {
const viewed = viewedIds.has(story.id);
return (
<button
key={story.id}
type="button"
onClick={() => {
markStoryViewed(story.id);
setActiveStory(story);
}}
className="flex w-21 shrink-0 flex-col items-center gap-2 text-center"
>
<StoryAvatar story={story} viewed={viewed} />
<span
className={cn(
"w-full truncate text-xs leading-tight",
viewed
? "font-medium text-muted-foreground"
: "font-semibold text-foreground"
)}
>
{story.title}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
<Dialog
open={!!activeStory}
onOpenChange={(open) => !open && setActiveStory(null)}
>
<DialogContent
showCloseButton={false}
className="w-[95vw] max-w-[min(95vw,calc((90vh-80px)*16/9))] sm:max-w-[min(95vw,calc((90vh-80px)*16/9))] max-h-[90vh] overflow-hidden border-0 bg-zinc-950 p-0 text-white sm:rounded-2xl"
>
{activeStory && (
<div className="flex flex-col">
<DialogTitle className="sr-only">{activeStory.title}</DialogTitle>
<DialogDescription className="sr-only">
Watch the latest update
</DialogDescription>
{activeStory.videoId && (
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl bg-black [transform:translateZ(0)]">
<iframe
className="absolute inset-0 size-full"
src={youTubeEmbedUrl(activeStory.videoId)}
title={activeStory.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
// the server's global no-referrer policy breaks YouTube
// embeds (error 153); send the origin for this frame only
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
<DialogClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 z-10 rounded-full bg-black/60 text-white backdrop-blur hover:bg-black/80 hover:text-white"
>
<XIcon className="size-5" />
<span className="sr-only">Close story</span>
</Button>
</DialogClose>
</div>
)}
{activeStory.videoId && (
<div className="flex items-center justify-between gap-3 px-6 py-4">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-white">
{activeStory.title}
</div>
</div>
{activeStory.cta && (
<div className="flex items-center gap-2">
{activeStory.cta.openInNewTab ? (
<ExternalLink
to={activeStory.cta.url}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{activeStory.cta.label}
</ExternalLink>
) : (
<Link
to={activeStory.cta.url}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{activeStory.cta.label}
</Link>
)}
</div>
)}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,25 @@
import { SVGAttributes } from "react";
export function BitcoinPaymentIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M63.0394 39.7416C58.7654 56.8846 41.4024 67.3176 24.2574 63.0426C7.11937 58.7686 -3.31363 41.4046 0.962367 24.2626C5.23437 7.1176 22.5974 -3.3164 39.7374 0.957596C56.8814 5.2316 67.3134 22.5976 63.0394 39.7416Z"
fill="#FB923C"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M33.0171 11.2938C34.6304 11.7261 35.5879 13.3844 35.1556 14.9977L34.8575 16.11L36.1559 16.4579L36.4539 15.3456C36.8862 13.7323 38.5445 12.7748 40.1579 13.2071C41.7712 13.6394 42.7286 15.2977 42.2963 16.9111L41.9448 18.2229C45.7919 20.1883 47.8803 24.6345 46.7172 28.9754C46.2003 30.9045 45.1168 32.5306 43.6938 33.7225C45.3314 35.9478 45.9793 38.8701 45.2085 41.7464L45.1384 42.0082C43.831 46.8875 38.8157 49.783 33.9365 48.4757L33.8452 48.4512L33.5472 49.5635C33.1149 51.1768 31.4565 52.1342 29.8432 51.7019C28.2298 51.2696 27.2724 49.6113 27.7047 47.998L28.0027 46.8857L26.7044 46.5378L26.4064 47.6501C25.9741 49.2634 24.3158 50.2209 22.7024 49.7886C21.0891 49.3563 20.1316 47.698 20.5639 46.0846L20.862 44.9723L16.6424 43.8417C15.0291 43.4094 14.0716 41.7511 14.5039 40.1378C14.9362 38.5244 16.5946 37.567 18.2079 37.9993L19.0671 38.2295L24.0892 19.4866L23.2301 19.2563C21.6167 18.824 20.6593 16.5946 21.0916 15.5524C21.5239 13.939 23.1822 12.9816 24.7955 13.4139L29.0151 14.5445L29.3131 13.4322C29.7454 11.8189 31.4037 10.8615 33.0171 11.2938ZM29.9317 21.052L28.2188 27.4445L36.8221 29.7497C38.5873 30.2227 40.4017 29.1752 40.8747 27.4099C41.3477 25.6447 40.3001 23.8303 38.5349 23.3573L29.9317 21.052ZM26.6533 33.287L24.9095 39.795L35.502 42.6332C37.1545 43.076 38.8531 42.0953 39.296 40.4427L39.3661 40.181C39.8476 38.3838 38.7811 36.5366 36.984 36.055L26.6533 33.287Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,28 @@
import { SVGAttributes } from "react";
export function GooglePayIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
{...props}
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.5 8a5.5 5.5 0 1 1-2.4-4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.5 8h5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View file

@ -29,7 +29,7 @@ export function LightningIcon(props: SVGAttributes<SVGElement>) {
/>
<path
d="M33.3099 12.4881C32.5628 12.3958 31.9053 12.6446 31.3666 13.0015C30.8466 13.346 30.3632 13.8383 29.8999 14.3907C28.9936 15.4713 27.9225 17.0832 26.5847 19.0983L19.0419 30.4596C18.1163 31.8537 17.3375 33.0266 16.8825 33.9955C16.4166 34.9876 16.1007 36.1567 16.7178 37.3017C17.335 38.4468 18.4871 38.8294 19.5742 38.9896C20.6357 39.1461 22.0465 39.146 23.7235 39.146H24.9235C25.8369 39.146 26.4043 39.1481 26.8221 39.197C27.2053 39.2418 27.2901 39.3106 27.3097 39.3265C27.3533 39.3618 27.3937 39.4022 27.4291 39.4457C27.445 39.4652 27.5135 39.549 27.5584 39.9311C27.6074 40.3476 27.6096 40.9132 27.6096 41.8239V41.9194C27.6095 44.3546 27.6095 46.3017 27.7679 47.7093C27.8484 48.4249 27.979 49.1014 28.2219 49.6748C28.4735 50.2688 28.884 50.8381 29.5581 51.1722C29.9135 51.3484 30.2962 51.4633 30.6901 51.5119C31.4372 51.6042 32.0947 51.3554 32.6334 50.9985C33.1534 50.654 33.6368 50.1617 34.1001 49.6093C35.0114 48.5227 36.0894 46.899 37.4375 44.8683L44.9581 33.5404C45.8837 32.1463 46.6625 30.9734 47.1175 30.0045C47.5834 29.0124 47.8993 27.8433 47.2822 26.6983C46.665 25.5532 45.5129 25.1706 44.4258 25.0104C43.3643 24.8539 41.9535 24.854 40.2765 24.854L39.0765 24.854C38.1631 24.854 37.5957 24.8519 37.1779 24.803C36.7947 24.7582 36.7099 24.6894 36.6903 24.6735C36.6467 24.6382 36.6063 24.5978 36.5709 24.5543C36.555 24.5348 36.4865 24.4509 36.4416 24.0689C36.3926 23.6524 36.3904 23.0868 36.3904 22.1761V22.0806C36.3905 19.6454 36.3905 17.6982 36.2321 16.2907C36.1516 15.5751 36.021 14.8986 35.7781 14.3252C35.5265 13.7312 35.116 13.1619 34.4419 12.8278C34.0865 12.6516 33.7038 12.5367 33.3099 12.4881Z"
fill="white"
fill="currentColor"
/>
</g>
</g>

View file

@ -0,0 +1,19 @@
import { SVGAttributes } from "react";
export function MastercardLogo(props: SVGAttributes<SVGElement>) {
return (
<svg
{...props}
viewBox="0 0 152 108"
xmlns="http://www.w3.org/2000/svg"
aria-label="Mastercard"
>
<circle cx="60" cy="54" r="36" fill="#EB001B" />
<circle cx="92" cy="54" r="36" fill="#F79E1B" />
<path
fill="#FF5F00"
d="M 76 21.75 A 36 36 0 0 1 76 86.25 A 36 36 0 0 1 76 21.75 Z"
/>
</svg>
);
}

View file

@ -0,0 +1,16 @@
import { SVGAttributes } from "react";
export function VisaLogo(props: SVGAttributes<SVGElement>) {
return (
<svg
{...props}
viewBox="0 0 750 471"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
aria-label="Visa"
>
<path d="M278.197 334.228l32.71-202.319h52.298l-32.74 202.319zM520.084 137.092c-10.37-3.99-26.62-8.27-46.91-8.27-51.71 0-88.13 26.71-88.45 65.01-.29 28.31 26.06 44.1 45.95 53.51 20.41 9.65 27.27 15.81 27.18 24.42-.14 13.18-16.13 19.21-31.06 19.21-20.78 0-31.83-2.97-48.89-10.27l-6.69-3.13-7.29 43.84c12.13 5.46 34.55 10.19 57.83 10.43 54.99 0 90.71-26.4 91.11-67.31.21-22.41-13.71-39.46-43.86-53.51-18.27-9.11-29.45-15.18-29.33-24.41 0-8.19 9.55-16.94 30.18-16.94 17.24-.27 29.72 3.59 39.45 7.58l4.72 2.3 7.13-42.45zM653.929 131.909h-40.42c-12.53 0-21.89 3.5-27.4 16.32l-77.67 186h54.93s8.98-24.34 11.01-29.69c6 0 59.36.08 86.99.08 1.57 6.92 6.41 29.61 6.41 29.61h48.54l-42.39-202.32zm-64.42 130.13c4.33-11.34 20.85-54.31 20.85-54.31-.31.52 4.3-11.4 6.94-18.79l3.54 16.97s10.02 46.18 12.11 55.6h-43.45v0.53zM233.585 131.909l-51.21 137.97-5.46-27.16c-9.53-31.32-39.21-65.25-72.4-82.24l46.83 173.59 55.34-.06 82.37-202.1h-55.47" />
<path d="M132.221 131.909H47.851l-.67 4.13c65.66 16.06 109.11 54.93 127.13 101.65l-18.34-89.16c-3.17-12.46-12.35-16.25-23.71-16.62" />
</svg>
);
}

View file

@ -6,6 +6,7 @@ import { useInfo } from "src/hooks/useInfo";
import {
ArrowRightLeftIcon,
BoxIcon,
BugIcon,
CloudBackupIcon,
CodeIcon,
@ -162,6 +163,11 @@ export default function SettingsLayout() {
</NavGroup>
<NavGroup label="Advanced">
{info?.backendType === "LDK" && (
<MenuItem to="/settings/node" icon={BoxIcon}>
Node
</MenuItem>
)}
<MenuItem to="/settings/developer" icon={CodeIcon}>
Developer
</MenuItem>

View file

@ -3,7 +3,7 @@ import {
CalendarSyncIcon,
CreditCardIcon,
} from "lucide-react";
import { Outlet } from "react-router";
import { Outlet, useMatch } from "react-router";
import AppHeader from "src/components/AppHeader";
import Loading from "src/components/Loading";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
@ -17,6 +17,7 @@ export default function WalletLayout() {
useSyncWallet();
const { data: info, hasChannelManagement } = useInfo();
const { data: balances } = useBalances(true);
const isOnchain = !!useMatch("/wallet/onchain");
if (!info || !balances) {
return <Loading />;
@ -59,7 +60,10 @@ export default function WalletLayout() {
<CreditCardIcon />
Buy
</ExternalLinkButton>
<WalletActionsMenu hasChannelManagement={!!hasChannelManagement} />
<WalletActionsMenu
hasChannelManagement={!!hasChannelManagement}
isOnchain={isOnchain}
/>
</div>
}
/>

View file

@ -14,6 +14,13 @@ export function HomeRedirect() {
return;
}
if (info.nodeMigrationFileCreated) {
// the hub is halted after creating a migration file and should not be
// used anymore, so keep showing the migration success instructions
navigate("/create-node-migration-file-success", { replace: true });
return;
}
const setupReturnTo = window.localStorage.getItem(
localStorageKeys.setupReturnTo
);

View file

@ -9,6 +9,12 @@ export function StartRedirect({ children }: React.PropsWithChildren) {
const navigate = useNavigate();
React.useEffect(() => {
if (info?.nodeMigrationFileCreated) {
// the hub is halted after creating a migration file and should not be
// used anymore, so keep showing the migration success instructions
navigate("/create-node-migration-file-success", { replace: true });
return;
}
if (!info || (info.setupCompleted && !info.running)) {
if (info && !info.albyAccountConnected && info.albyUserIdentifier) {
navigate("/alby/auth");

View file

@ -1,10 +1,13 @@
import { toast } from "sonner";
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
import { ListTransactionsResponse, Transaction } from "src/types";
import { request } from "src/utils/request";
const LABEL_COLUMN_PREFIX = "label_";
// Use a large page size to keep the number of round-trips low when
// exporting wallets that have thousands of transactions.
const EXPORT_TRANSACTIONS_PAGE_SIZE = 1000;
// based on https://stackoverflow.com/a/68146412
const escapeCsvCell = (raw: string) => {
const safe = /^[\t\r ]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
@ -57,13 +60,14 @@ export const convertToCSV = (transactions: Transaction[]) => {
};
export const handleExportTransactions = async (appId?: number) => {
const toastId = toast.loading("Exporting transactions…");
try {
// Fetch all transactions by paginating through all pages
let allTransactions: Transaction[] = [];
let offset = 0;
while (true) {
let url = `/api/transactions?limit=${LIST_TRANSACTIONS_LIMIT}&offset=${offset}`;
let url = `/api/transactions?limit=${EXPORT_TRANSACTIONS_PAGE_SIZE}&offset=${offset}`;
if (appId) {
url += `&appId=${appId}`;
}
@ -75,11 +79,15 @@ export const handleExportTransactions = async (appId?: number) => {
}
allTransactions = [...allTransactions, ...data.transactions];
toast.loading(
`Exporting ${allTransactions.length.toLocaleString()} transactions…`,
{ id: toastId }
);
if (data.transactions.length < LIST_TRANSACTIONS_LIMIT) {
if (data.transactions.length < EXPORT_TRANSACTIONS_PAGE_SIZE) {
break;
}
offset += LIST_TRANSACTIONS_LIMIT;
offset += EXPORT_TRANSACTIONS_PAGE_SIZE;
}
// Convert to CSV and create download
@ -96,9 +104,11 @@ export const handleExportTransactions = async (appId?: number) => {
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast("Transactions saved to your downloads folder");
toast.success("Transactions saved to your downloads folder", {
id: toastId,
});
} catch (error) {
console.error("Error downloading transactions:", error);
toast.error("Failed to export transactions");
toast.error("Failed to export transactions", { id: toastId });
}
};

View file

@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import { type VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { cn } from "src/lib/utils";
import { toggleVariants } from "src/components/ui/toggleVariants";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View file

@ -0,0 +1,24 @@
import * as React from "react";
import { type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cn } from "src/lib/utils";
import { toggleVariants } from "src/components/ui/toggleVariants";
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle };

View file

@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);

View file

@ -5,6 +5,8 @@ export const localStorageKeys = {
authToken: "authToken",
supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
aiHeroDismissed: "aiHeroDismissed",
cardsHeroDismissed: "cardsHeroDismissed",
homeStoriesViewed: "homeStoriesViewed",
};
export const ONCHAIN_DUST_SATS = 1000;
@ -14,6 +16,7 @@ export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 10_000;
export const LIST_TRANSACTIONS_LIMIT = 20;
export const LIST_APPS_LIMIT = 20;
export const MAX_FREE_SUBWALLETS = 3;
export const APP_SELECT_APPS_LIMIT = 100;
export const PAY_FROM_SELECT_APPS_LIMIT = 100;
export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`;

View file

@ -4,7 +4,7 @@ import { BalancesResponse } from "src/types";
import { swrFetcher } from "src/utils/swr";
const pollConfiguration: SWRConfiguration = {
refreshInterval: 3000,
refreshInterval: 10000,
};
export function useBalances(poll = false) {

View file

@ -3,6 +3,7 @@
import { ALBY_ACCOUNT_APP_NAME } from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApps } from "src/hooks/useApps";
import { useBalances } from "src/hooks/useBalances";
import { useChannels } from "src/hooks/useChannels";
import { useInfo } from "src/hooks/useInfo";
import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo";
@ -24,6 +25,7 @@ interface UseOnboardingDataResponse {
export const useOnboardingData = (): UseOnboardingDataResponse => {
const { data: albyMe } = useAlbyMe();
const { data: appsData } = useApps();
const { data: balances } = useBalances();
const { data: channels } = useChannels();
const { data: info, hasChannelManagement, hasMnemonic } = useInfo();
const { data: nodeConnectionInfo } = useNodeConnectionInfo();
@ -31,6 +33,7 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
const isLoading =
!appsData ||
!balances ||
!channels ||
!info ||
!nodeConnectionInfo ||
@ -55,10 +58,12 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
const hasCustomApp =
appsData &&
appsData.apps.find((x) => x.name !== ALBY_ACCOUNT_APP_NAME) !== undefined;
const hasTransaction = transactions.totalCount > 0;
const hasFundsOrTransaction =
transactions.totalCount > 0 || balances.lightning.totalSpendableSat > 0;
const checklistItems: Omit<ChecklistItem, "disabled">[] = [
...(hasChannelManagement
...(hasChannelManagement &&
!(info.jitChannelsEnabled && info.jitChannelsLiquiditySource)
? [
{
title: "Open your first channel",
@ -81,10 +86,9 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
]
: []),
{
title: "Send or receive your first payment",
description:
"Use your newly opened channel to make a transaction on the Lightning Network.",
checked: hasTransaction,
title: "Receive your first payment",
description: "Add funds to your wallet to start using it.",
checked: hasFundsOrTransaction,
to: "/wallet",
},
{

View file

@ -2,6 +2,8 @@ import React from "react";
import { isHttpMode } from "src/utils/isHttpMode";
const STORAGE_KEY = "bitcoin-protocol-handler-registered";
export function useRegisterProtocolHandler(basePath: string) {
React.useEffect(() => {
if (!isHttpMode() || !("registerProtocolHandler" in navigator)) {
@ -9,9 +11,20 @@ export function useRegisterProtocolHandler(basePath: string) {
}
try {
// Browsers re-prompt every time registerProtocolHandler is called if the
// user previously dismissed the prompt without accepting or denying it.
// Limit to once per browser session so we don't ask on every page load,
// but users still get re-asked in a new session if they didn't opt in.
// sessionStorage access can throw in restricted/private modes, so it's
// inside the same try/catch as registerProtocolHandler.
if (sessionStorage.getItem(STORAGE_KEY)) {
return;
}
const normalizedBasePath = basePath.replace(/\/$/, "");
const handlerUrl = `${window.location.origin}${normalizedBasePath}/wallet/send?bip21=%s`;
navigator.registerProtocolHandler("bitcoin", handlerUrl);
sessionStorage.setItem(STORAGE_KEY, "true");
} catch (e) {
console.error("Failed to register bitcoin protocol handler", e);
}

View file

@ -4,15 +4,56 @@ import { ListTransactionsResponse } from "src/types";
import { swrFetcher } from "src/utils/swr";
const pollConfiguration: SWRConfiguration = {
refreshInterval: 3000,
refreshInterval: 10000,
};
export function getTransactionsUrl(appId?: number, limit = 100, page = 1) {
export type TransactionFilters = {
searchTerm?: string;
type?: "incoming" | "outgoing";
minAmountSat?: number;
hideFailed?: boolean;
};
export const defaultTransactionFilters: TransactionFilters = {};
export function hasActiveTransactionFilters(filters: TransactionFilters) {
return (
!!filters.searchTerm ||
!!filters.type ||
(filters.minAmountSat ?? 0) > 0 ||
!!filters.hideFailed
);
}
export function getTransactionsUrl(
appId?: number,
limit = 100,
page = 1,
filters?: TransactionFilters
) {
const offset = (page - 1) * limit;
let url = `/api/transactions?limit=${limit}&offset=${offset}`;
const searchParams = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (appId) {
url += `&appId=${appId}`;
searchParams.set("appId", String(appId));
}
if (filters?.searchTerm) {
searchParams.set("search", filters.searchTerm);
}
if (filters?.type) {
searchParams.set("type", filters.type);
}
if (filters?.minAmountSat && filters.minAmountSat > 0) {
searchParams.set("minAmountSat", String(filters.minAmountSat));
}
if (filters?.hideFailed) {
searchParams.set("hideFailed", "true");
}
const url = `/api/transactions?${searchParams.toString()}`;
return url;
}
@ -21,9 +62,10 @@ export function useTransactions(
appId?: number,
poll = false,
limit = 100,
page = 1
page = 1,
filters?: TransactionFilters
) {
const url = getTransactionsUrl(appId, limit, page);
const url = getTransactionsUrl(appId, limit, page, filters);
return useSWR<ListTransactionsResponse>(
url,

View file

@ -1,35 +0,0 @@
import { BackendType } from "src/types";
type BackendTypeConfig = {
hasMnemonic: boolean;
hasChannelManagement: boolean;
hasNodeBackup: boolean;
};
export const backendTypeConfigs: Record<BackendType, BackendTypeConfig> = {
LND: {
hasMnemonic: false,
hasChannelManagement: true,
hasNodeBackup: false,
},
LDK: {
hasMnemonic: true,
hasChannelManagement: true,
hasNodeBackup: true,
},
PHOENIX: {
hasMnemonic: false,
hasChannelManagement: false,
hasNodeBackup: false,
},
CASHU: {
hasMnemonic: true,
hasChannelManagement: false,
hasNodeBackup: false,
},
CLN: {
hasMnemonic: false,
hasChannelManagement: true,
hasNodeBackup: false,
},
};

View file

@ -0,0 +1,68 @@
import { ReactElement } from "react";
import { LDKIcon } from "src/components/icons/LDK";
import { PhoenixdIcon } from "src/components/icons/Phoenixd";
import { BackendType } from "src/types";
import barkDark from "src/assets/images/node/bark-dark.svg";
import barkLight from "src/assets/images/node/bark-light.svg";
import cashu from "src/assets/images/node/cashu.png";
import cln from "src/assets/images/node/cln.png";
import lnd from "src/assets/images/node/lnd.png";
type BackendTypeConfig = {
title: string;
icon: ReactElement;
hasMnemonic: boolean;
hasChannelManagement: boolean;
hasNodeBackup: boolean;
};
export const backendTypeConfigs: Record<BackendType, BackendTypeConfig> = {
LDK: {
title: "LDK",
icon: <LDKIcon />,
hasMnemonic: true,
hasChannelManagement: true,
hasNodeBackup: true,
},
PHOENIX: {
title: "phoenixd",
icon: <PhoenixdIcon />,
hasMnemonic: false,
hasChannelManagement: false,
hasNodeBackup: false,
},
LND: {
title: "LND",
icon: <img src={lnd} />,
hasMnemonic: false,
hasChannelManagement: true,
hasNodeBackup: false,
},
CASHU: {
title: "Cashu Mint",
icon: <img src={cashu} />,
hasMnemonic: true,
hasChannelManagement: false,
hasNodeBackup: false,
},
CLN: {
title: "CLN",
icon: <img src={cln} />,
hasMnemonic: false,
hasChannelManagement: true,
hasNodeBackup: false,
},
BARK: {
title: "Bark",
icon: (
<>
<img src={barkLight} className="dark:hidden" />
<img src={barkDark} className="hidden dark:block" />
</>
),
hasMnemonic: true,
hasChannelManagement: false,
hasNodeBackup: false,
},
};

View file

@ -18,6 +18,7 @@ import Unlock from "src/screens/Unlock";
import { Welcome } from "src/screens/Welcome";
import AlbyAuthRedirect from "src/screens/alby/AlbyAuthRedirect";
import { AI } from "src/screens/ai/AI";
import { Cards } from "src/screens/cards/Cards";
import { AlbyEarn } from "src/screens/alby/AlbyEarn";
import SupportAlby from "src/screens/alby/SupportAlby";
import AppDetails from "src/screens/apps/AppDetails";
@ -36,7 +37,6 @@ import { FirstChannel } from "src/screens/channels/first/FirstChannel";
import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel";
import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel";
import { AlbyCliSkill } from "src/screens/internal-apps/AlbyCliSkill";
import { Bitrefill } from "src/screens/internal-apps/Bitrefill";
import { BuzzPay } from "src/screens/internal-apps/BuzzPay";
import { LightningMessageboard } from "src/screens/internal-apps/LightningMessageboard";
import { SimpleBoost } from "src/screens/internal-apps/SimpleBoost";
@ -49,6 +49,7 @@ import Peers from "src/screens/peers/Peers";
import { About } from "src/screens/settings/About";
import { AlbyAccount } from "src/screens/settings/AlbyAccount";
import { AutoUnlock } from "src/screens/settings/AutoUnlock";
import { NodeSettings } from "src/screens/settings/NodeSettings";
import Backup from "src/screens/settings/Backup";
import { ChangeUnlockPassword } from "src/screens/settings/ChangeUnlockPassword";
import DebugTools from "src/screens/settings/DebugTools";
@ -62,6 +63,7 @@ import { SetupFinish } from "src/screens/setup/SetupFinish";
import { SetupNode } from "src/screens/setup/SetupNode";
import { SetupPassword } from "src/screens/setup/SetupPassword";
import { SetupSecurity } from "src/screens/setup/SetupSecurity";
import { BarkForm } from "src/screens/setup/node/BarkForm";
import { CLNForm } from "src/screens/setup/node/CLNForm";
import { CashuForm } from "src/screens/setup/node/CashuForm";
import { LDKForm } from "src/screens/setup/node/LDKForm";
@ -252,6 +254,11 @@ const routes: RouteObject[] = [
element: <AutoUnlock />,
handle: { crumb: () => "Auto Unlock" },
},
{
path: "node",
element: <NodeSettings />,
handle: { crumb: () => "Node" },
},
{
path: "change-unlock-password",
element: <ChangeUnlockPassword />,
@ -347,10 +354,6 @@ const routes: RouteObject[] = [
path: "zapplanner",
element: <ZapPlanner />,
},
{
path: "bitrefill",
element: <Bitrefill />,
},
{
path: "alby-cli-skill",
element: <AlbyCliSkill />,
@ -474,6 +477,12 @@ const routes: RouteObject[] = [
element: <AI />,
handle: { crumb: () => "AI & Agents" },
},
{
path: "cards",
element: <DefaultRedirect />,
handle: { crumb: () => "Cards" },
children: [{ index: true, element: <Cards /> }],
},
],
},
{
@ -553,6 +562,10 @@ const routes: RouteObject[] = [
path: "cln",
element: <CLNForm />,
},
{
path: "bark",
element: <BarkForm />,
},
{
path: "preset",
element: <PresetNodeForm />,

View file

@ -100,7 +100,7 @@ export function ConnectAlbyAccount({ connectUrl }: ConnectAlbyAccountProps) {
</CardHeader>
</Card>
</div>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<div className="flex flex-col items-center justify-center gap-2 mt-5">
<LinkButton to={connectUrl || "/alby/auth"} size="lg">
Connect
</LinkButton>

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