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
This commit is contained in:
Roland 2026-06-04 11:57:29 +07:00 committed by GitHub
parent 56b1ce895f
commit 84e56a23db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1161 additions and 137 deletions

View file

@ -220,6 +220,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.
@ -283,6 +284,15 @@ _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 required by the Ark server (pre-public mainnet launch).
### 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`.

View file

@ -1688,6 +1688,17 @@ 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 and have no
// remote-backup mechanism, so they cannot run in environments without
// persistent volumes (e.g. Alby Cloud). 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

View file

@ -58,7 +58,8 @@ 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 {
return nil, "", fmt.Errorf(
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
}

View file

@ -6,6 +6,7 @@ const (
PhoenixBackendType = "PHOENIX"
CashuBackendType = "CASHU"
CLNBackendType = "CLN"
BarkBackendType = "BARK"
)
const (
@ -63,6 +64,9 @@ 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"`
}
func (c *AppConfig) IsDefaultClientId() bool {

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View file

@ -82,8 +82,7 @@ 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.",
description: "Add funds to your wallet, then make your first payment.",
checked: hasTransaction,
to: "/wallet",
},

View file

@ -32,4 +32,9 @@ export const backendTypeConfigs: Record<BackendType, BackendTypeConfig> = {
hasChannelManagement: true,
hasNodeBackup: false,
},
BARK: {
hasMnemonic: true,
hasChannelManagement: false,
hasNodeBackup: false,
},
};

View file

@ -63,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";
@ -560,6 +561,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>

View file

@ -41,7 +41,12 @@ import { InfoResponse, MnemonicResponse } from "src/types";
import { request } from "src/utils/request";
export default function Backup() {
const { data: info, hasMnemonic } = useInfo();
const {
data: info,
hasMnemonic,
hasChannelManagement,
hasNodeBackup,
} = useInfo();
const { data: me } = useAlbyMe();
const [unlockPassword, setUnlockPassword] = useState("");
const [decryptedMnemonic, setDecryptedMnemonic] = useState("");
@ -80,9 +85,11 @@ export default function Backup() {
description={
<>
<span className="text-muted-foreground">
Backup your recovery phrase and channel states. These backups are
for disaster recovery only. To migrate your node, please use the
migration tool.{" "}
Backup your recovery phrase
{hasChannelManagement && " and channel states"}. These backups are
for disaster recovery only.
{hasNodeBackup &&
" To migrate your node, please use the migration tool."}{" "}
</span>
<a
href="https://guides.getalby.com/user-guide/alby-hub/backups-and-recover"
@ -104,9 +111,10 @@ export default function Backup() {
<h3 className="text-lg font-medium">Recovery Phrase</h3>
<p className="text-sm text-muted-foreground">
Your recovery phrase is a group of 12 random words that back
up your wallet on-chain balance. Using them is the only way to
recover access to your wallet on another machine or when you
lose your unlock password.
up your wallet{" "}
{info?.backendType === "LDK" ? "on-chain balance" : "balance"}
. Using them is the only way to recover access to your wallet
on another machine or when you lose your unlock password.
</p>
</div>
<Alert variant="destructive">
@ -117,6 +125,16 @@ export default function Backup() {
phrase, you will lose access to your funds.
</AlertDescription>
</Alert>
{info?.backendType === "BARK" && (
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertTitle>Bark Support Coming Soon</AlertTitle>
<AlertDescription>
During the beta period, your recovery phrase is not
sufficient to restore your funds.
</AlertDescription>
</Alert>
)}
{info?.backendType === "CASHU" && <CashuMnemonicWarning />}
<div>
@ -166,113 +184,117 @@ export default function Backup() {
</>
)}
<div className="flex flex-col gap-8">
<div>
<h3 className="text-lg font-medium">Channels Backup</h3>
<p className="text-sm text-muted-foreground">
Your lightning balance can only be recovered on-chain by closing
your lightning channels. In case of recovery of your Alby Hub a
request will be sent to your peers to close your existing
channels. To recover the funds from these channels, a channel
backup needs to be created every time you open a new channel.
</p>
</div>
{hasChannelManagement && (
<div className="flex flex-col gap-8">
<div>
<h3 className="text-lg font-medium">Channels Backup</h3>
<p className="text-sm text-muted-foreground">
Your lightning balance can only be recovered on-chain by closing
your lightning channels. In case of recovery of your Alby Hub a
request will be sent to your peers to close your existing
channels. To recover the funds from these channels, a channel
backup needs to be created every time you open a new channel.
</p>
</div>
<div>
<div className="flex gap-2 mb-1 items-center">
<h3 className="text-sm font-medium">Automatic Channels Backup</h3>
<div>
<div className="flex gap-2 mb-1 items-center">
<h3 className="text-sm font-medium">
Automatic Channels Backup
</h3>
{info?.albyAccountConnected ? (
<Badge variant={"positive"}>Active</Badge>
) : (
<Badge>Recommended</Badge>
)}
</div>
{info?.albyAccountConnected ? (
<Badge variant={"positive"}>Active</Badge>
<>
<p className="text-muted-foreground text-sm mb-8">
Your channel state is backed up automatically after each
channel creation. Using an external recovery tool and your
recovery phrase, you can recover your funds from channels to
your on-chain balance as long as your channel partners are
online.
</p>
{info?.vssSupported && (
<>
<div className="flex gap-2 mb-1 items-center">
<h3 className="text-sm font-medium">
Dynamic Channels Backup With Instant Recovery
</h3>
{me?.subscription.plan_code && info.ldkVssEnabled ? (
<Badge variant={"positive"}>Active</Badge>
) : (
<Badge className="shrink-0">Alby Cloud</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mb-4">
When enabled, your channels state is dynamically updated
and stored end-to-end encrypted by Alby's Versioned
Storage Service. This allows you to recover your
lightning balance with your recovery phrase alone,
without having to close your channels.
</p>
{!info.ldkVssEnabled &&
(!me?.subscription.plan_code ? (
<UpgradeDialog>
<Button variant="secondary" size={"lg"}>
Upgrade to Enable Dynamic Channels Backup
</Button>
</UpgradeDialog>
) : (
<DynamicChannelsBackupDialog info={info} />
))}
</>
)}
</>
) : (
<Badge>Recommended</Badge>
<div className="flex flex-col gap-8">
<div>
<p className="text-muted-foreground text-sm mb-4">
Link your Alby Account to enable automatic channel backups
after each channel creation.
</p>
<Button
type="button"
variant={"secondary"}
className="flex gap-2 justify-center"
onClick={() => navigate("/alby/account")}
>
<Link2Icon />
Link Alby Account to Enable
</Button>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<h3 className="text-sm font-medium">
Manual Channels Backup
</h3>
<Badge variant={"positive"}>Active</Badge>
</div>
<p>
<span className="text-muted-foreground text-sm">
To backup your channels state manually, without Alby
Account linked, follow the
</span>{" "}
<ExternalLink
to="https://guides.getalby.com/user-guide/alby-hub/backups-and-recover#alby-hub-self-hosted-without-an-alby-account"
className="underline inline-flex items-center text-sm"
>
manual backups guide
<ExternalLinkIcon className="size-4 ml-1" />
</ExternalLink>
</p>
</div>
</div>
)}
</div>
{info?.albyAccountConnected ? (
<>
<p className="text-muted-foreground text-sm mb-8">
Your channel state is backed up automatically after each
channel creation. Using an external recovery tool and your
recovery phrase, you can recover your funds from channels to
your on-chain balance as long as your channel partners are
online.
</p>
{info?.vssSupported && (
<>
<div className="flex gap-2 mb-1 items-center">
<h3 className="text-sm font-medium">
Dynamic Channels Backup With Instant Recovery
</h3>
{me?.subscription.plan_code && info.ldkVssEnabled ? (
<Badge variant={"positive"}>Active</Badge>
) : (
<Badge className="shrink-0">Alby Cloud</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mb-4">
When enabled, your channels state is dynamically updated
and stored end-to-end encrypted by Alby's Versioned
Storage Service. This allows you to recover your lightning
balance with your recovery phrase alone, without having to
close your channels.
</p>
{!info.ldkVssEnabled &&
(!me?.subscription.plan_code ? (
<UpgradeDialog>
<Button variant="secondary" size={"lg"}>
Upgrade to Enable Dynamic Channels Backup
</Button>
</UpgradeDialog>
) : (
<DynamicChannelsBackupDialog info={info} />
))}
</>
)}
</>
) : (
<div className="flex flex-col gap-8">
<div>
<p className="text-muted-foreground text-sm mb-4">
Link your Alby Account to enable automatic channel backups
after each channel creation.
</p>
<Button
type="button"
variant={"secondary"}
className="flex gap-2 justify-center"
onClick={() => navigate("/alby/account")}
>
<Link2Icon />
Link Alby Account to Enable
</Button>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<h3 className="text-sm font-medium">
Manual Channels Backup
</h3>
<Badge variant={"positive"}>Active</Badge>
</div>
<p>
<span className="text-muted-foreground text-sm">
To backup your channels state manually, without Alby
Account linked, follow the
</span>{" "}
<ExternalLink
to="https://guides.getalby.com/user-guide/alby-hub/backups-and-recover#alby-hub-self-hosted-without-an-alby-account"
className="underline inline-flex items-center text-sm"
>
manual backups guide
<ExternalLinkIcon className="size-4 ml-1" />
</ExternalLink>
</p>
</div>
</div>
)}
</div>
</div>
)}
{!hasMnemonic && !info?.vssSupported && (
{!hasMnemonic && !hasChannelManagement && !info?.vssSupported && (
<p className="text-sm text-muted-foreground">
No recovery phrase or channel state backup present.
</p>

View file

@ -50,8 +50,9 @@ export function ImportMnemonic() {
mnemonic,
nextBackupReminder: sixMonthsLater.toISOString(),
});
setupStore.setHasImportedMnemonic(true);
navigate(`/setup/security`);
navigate(`/setup/node`);
}
return (

View file

@ -1,8 +1,15 @@
import { useEffect } from "react";
import Container from "src/components/Container";
import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
import { LinkButton } from "src/components/ui/custom/link-button";
import useSetupStore from "src/state/SetupStore";
export function SetupAdvanced() {
useEffect(() => {
// in case the user goes back, reset the imported mnemonic flag
useSetupStore.getState().setHasImportedMnemonic(false);
}, []);
return (
<Container>
<div className="grid gap-5">

View file

@ -8,6 +8,7 @@ import { Button } from "src/components/ui/button";
import { cn } from "src/lib/utils";
import { BackendType } from "src/types";
import bark from "src/assets/images/node/bark.jpg";
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";
@ -42,6 +43,10 @@ const backendTypeDisplayConfigs: Partial<
title: "CLN",
icon: <img src={cln} />,
},
BARK: {
title: "Bark",
icon: <img src={bark} />,
},
};
const backendTypeDisplayConfigList = Object.entries(
@ -64,7 +69,8 @@ export function SetupNode() {
navigate(`/setup/node/${selectedBackendType.toLowerCase()}`);
}
const hasImportedMnemonic = !!setupStore.nodeInfo.mnemonic;
const hasImportedMnemonic =
!!setupStore.nodeInfo.mnemonic && setupStore.hasImportedMnemonic;
return (
<Container>

View file

@ -1,5 +1,7 @@
import {
ClockIcon,
HandCoinsIcon,
HardDriveIcon,
LandmarkIcon,
ShieldAlertIcon,
UnlockIcon,
@ -36,17 +38,18 @@ export function SetupSecurity() {
/>
<div className="flex flex-col gap-6 w-full mt-6">
{store.nodeInfo.backendType !== "CASHU" && (
<div className="flex gap-3 items-center">
<div className="shrink-0">
<HandCoinsIcon className="size-6" />
{store.nodeInfo.backendType !== "CASHU" &&
store.nodeInfo.backendType !== "BARK" && (
<div className="flex gap-3 items-center">
<div className="shrink-0">
<HandCoinsIcon className="size-6" />
</div>
<span className="text-sm text-muted-foreground">
Alby Hub is a spending wallet - do not keep all your savings
on it!
</span>
</div>
<span className="text-sm text-muted-foreground">
Alby Hub is a spending wallet - do not keep all your savings on
it!
</span>
</div>
)}
)}
{store.nodeInfo.backendType === "CASHU" && (
<div className="flex gap-3 items-center">
<div className="shrink-0">
@ -58,6 +61,31 @@ export function SetupSecurity() {
</span>
</div>
)}
{store.nodeInfo.backendType === "BARK" && (
<>
<div className="flex gap-3 items-center">
<LandmarkIcon className="size-6 shrink-0" />
<span className="text-sm text-muted-foreground">
Bark is in beta - use at your own risk!
</span>
</div>
<div className="flex gap-3 items-center">
<ClockIcon className="size-6 shrink-0" />
<span className="text-sm text-muted-foreground">
Your funds will be refreshed periodically which will incur a
small fee.
</span>
</div>
<div className="flex gap-3 items-center">
<HardDriveIcon className="size-6 shrink-0" />
<span className="text-sm text-muted-foreground">
During beta, your funds{" "}
<span className="underline">cannot</span> be recovered from
your recovery phrase alone.
</span>
</div>
</>
)}
<div className="flex gap-3 items-center">
<div className="shrink-0">
<UnlockIcon className="size-6" />
@ -81,7 +109,7 @@ export function SetupSecurity() {
choose the LDK node type.
</span>
</div>
) : (
) : store.nodeInfo.backendType === "BARK" ? null : (
<div className="flex gap-3 items-center">
<div className="shrink-0">
<ShieldAlertIcon className="size-6" />

View file

@ -0,0 +1,33 @@
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router";
import useSetupStore from "src/state/SetupStore";
import * as bip39 from "@scure/bip39";
import Loading from "src/components/Loading";
export function BarkForm() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
useEffect(() => {
if (!useSetupStore.getState().nodeInfo.mnemonic) {
useSetupStore.getState().updateNodeInfo({
mnemonic: bip39.generateMnemonic(wordlist, 128),
});
}
useSetupStore.getState().updateNodeInfo({
backendType: "BARK",
});
navigate("/setup/security", {
replace: true,
});
}, [navigate, searchParams]);
return (
<>
<title>Loading... · Alby Hub</title>
<Loading />
</>
);
}

View file

@ -21,7 +21,9 @@ export function LDKForm() {
useSetupStore.getState().updateNodeInfo({
backendType: "LDK",
});
navigate("/setup/security");
navigate("/setup/security", {
replace: true,
});
}, [navigate, searchParams]);
return (

View file

@ -24,7 +24,9 @@ export function PresetNodeForm() {
});
}
navigate("/setup/security");
navigate("/setup/security", {
replace: true,
});
}, [info, navigate, searchParams]);
return (

View file

@ -4,18 +4,22 @@ import { create } from "zustand";
interface SetupStore {
readonly nodeInfo: SetupNodeInfo;
readonly unlockPassword: string;
readonly hasImportedMnemonic: boolean;
updateNodeInfo(nodeInfo: SetupNodeInfo): void;
setUnlockPassword(unlockPassword: string): void;
setHasImportedMnemonic(hasImportedMnemonic: boolean): void;
}
const useSetupStore = create<SetupStore>((set) => ({
nodeInfo: {},
unlockPassword: "",
hasImportedMnemonic: false,
updateNodeInfo: (nodeInfo) =>
set((state) => ({
nodeInfo: { ...state.nodeInfo, ...nodeInfo },
})),
setUnlockPassword: (unlockPassword) => set({ unlockPassword }),
setHasImportedMnemonic: (hasImportedMnemonic) => set({ hasImportedMnemonic }),
}));
export default useSetupStore;

View file

@ -11,7 +11,7 @@ import {
WalletMinimalIcon,
} from "lucide-react";
export type BackendType = "LND" | "LDK" | "PHOENIX" | "CASHU" | "CLN";
export type BackendType = "LND" | "LDK" | "PHOENIX" | "CASHU" | "CLN" | "BARK";
export type Nip47RequestMethod =
| "get_info"

1
go.mod
View file

@ -19,6 +19,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tyler-smith/go-bip39 v1.1.0
github.com/wailsapp/wails/v2 v2.12.0
gitlab.com/ark-bitcoin/bark-ffi-bindings/golang v0.6.2
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.79.3

2
go.sum
View file

@ -689,6 +689,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
gitlab.com/ark-bitcoin/bark-ffi-bindings/golang v0.6.2 h1:UwqTPL4dmG4N+RDJaci6D2MhiWvBbBsCss4nDbYt8TM=
gitlab.com/ark-bitcoin/bark-ffi-bindings/golang v0.6.2/go.mod h1:1jAwB/XR4i3D72fz3qWAd41tQLYcOCGfWZHMagn5fNg=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/etcd/api/v3 v3.5.16 h1:WvmyJVbjWqK4R1E+B12RRHz3bRGy9XVfh++MgbN+6n0=

864
lnclient/bark/bark.go Normal file
View file

@ -0,0 +1,864 @@
package bark
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
decodepay "github.com/nbd-wtf/ln-decodepay"
"github.com/sirupsen/logrus"
bark "gitlab.com/ark-bitcoin/bark-ffi-bindings/golang/bark"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/notifications"
)
const (
// Subsystem name reported on movements produced when a lightning receive is
// claimed (see bark's Subsystem::LIGHTNING_RECEIVE).
lightningReceiveSubsystem = "lightning_receive"
// Subsystem name reported on movements produced for outgoing lightning
// payments (see bark's Subsystem::LIGHTNING_SEND).
lightningSendSubsystem = "lightning_send"
// Movement status reported once a movement has settled. A movement first
// appears as "pending" and is updated to this once complete.
movementStatusSuccessful = "successful"
// Movement status reported when a send was definitively not paid.
movementStatusFailed = "failed"
// Grace period to allow the notification loop to unwind on shutdown.
shutdownGracePeriod = 10 * time.Second
)
// Config holds the user-configurable settings for connecting to an Ark server.
type Config struct {
// Network is the bitcoin network name (e.g. "signet", "bitcoin").
Network string
// ServerAddress is the Ark server URL.
ServerAddress string
// EsploraAddress is the Esplora server URL used for chain data.
EsploraAddress string
// ServerAccessToken is an optional access token required by some Ark
// servers (currently used to gate mainnet access ahead of a public launch).
ServerAccessToken string
}
type BarkService struct {
wallet *bark.Wallet
workDir string
network string
eventPublisher events.EventPublisher
pubkey string
cancelFn context.CancelFunc
loopWg sync.WaitGroup
// payment_hash -> waiter that handleLightningSendMovement signals.
inflightSends map[string]chan sendResult
inflightSendsMtx sync.Mutex
}
type sendResult struct {
preimage string
feeMsat uint64
err error
}
// parseNetwork maps an Alby Hub network name onto a bark network.
func parseNetwork(network string) (bark.Network, error) {
switch network {
case "bitcoin", "mainnet":
return bark.NetworkBitcoin, nil
case "testnet":
return bark.NetworkTestnet, nil
case "signet":
return bark.NetworkSignet, nil
case "regtest":
return bark.NetworkRegtest, nil
default:
return 0, fmt.Errorf("unsupported bark network: %q", network)
}
}
func NewBarkService(ctx context.Context, eventPublisher events.EventPublisher, workDir, mnemonic string, config Config) (lnclient.LNClient, error) {
if mnemonic == "" {
return nil, errors.New("no mnemonic configured")
}
if workDir == "" {
return nil, errors.New("no bark work directory configured")
}
if config.ServerAddress == "" {
return nil, errors.New("no bark server address configured")
}
network, err := parseNetwork(config.Network)
if err != nil {
return nil, err
}
// Usually, you have two wait 2 blocks. You can set nb_min_round_confirmations=0 to make it go faster.
roundTxRequiredConfirmations := uint32(0)
cfg := bark.Config{
ServerAddress: config.ServerAddress,
Network: network,
RoundTxRequiredConfirmations: &roundTxRequiredConfirmations,
}
esploraAddress := config.EsploraAddress
if esploraAddress != "" {
cfg.EsploraAddress = &esploraAddress
}
if config.ServerAccessToken != "" {
token := config.ServerAccessToken
cfg.ServerAccessToken = &token
}
_, statErr := os.Stat(workDir)
isFirstSetup := statErr != nil && errors.Is(statErr, os.ErrNotExist)
logger.Logger.WithFields(logrus.Fields{
"workDir": workDir,
"isFirstSetup": isFirstSetup,
}).Info("Opening Bark wallet")
var wallet *bark.Wallet
if isFirstSetup {
wallet, err = bark.WalletCreate(mnemonic, cfg, workDir, false)
} else {
wallet, err = bark.WalletOpen(mnemonic, cfg, workDir)
}
if err != nil {
return nil, fmt.Errorf("failed to open bark wallet: %w", err)
}
// Bark provides a built-in background daemon that periodically syncs with
// the Ark server and blockchain, participates in rounds, and — crucially for
// us — claims incoming lightning receives via the mailbox (it long-polls for
// payment notifications and reveals the preimage, crediting the balance). We
// don't poll for receives ourselves; instead we observe the resulting wallet
// notifications (see runNotificationLoop) to emit payment-received events.
if err := wallet.RunDaemon(nil); err != nil {
logger.Logger.WithError(err).Warn("Bark daemon failed to start")
}
loopCtx, cancelFn := context.WithCancel(context.Background())
bs := &BarkService{
wallet: wallet,
workDir: workDir,
network: config.Network,
eventPublisher: eventPublisher,
pubkey: wallet.Fingerprint(),
cancelFn: cancelFn,
inflightSends: make(map[string]chan sendResult),
}
// Run maintenance immediately on startup so a wallet that was briefly
// offline refreshes any VTXOs that drifted towards expiry before they are
// swept by the server. This is fire-and-forget as it may join an Ark round
// and take some time.
go func() {
if err := bs.wallet.Maintenance(); err != nil {
logger.Logger.WithError(err).Warn("Bark startup maintenance failed")
}
}()
bs.loopWg.Add(1)
go bs.runNotificationLoop(loopCtx)
return bs, nil
}
// runNotificationLoop consumes the wallet's notification stream and publishes a
// payment-received event whenever the daemon claims an incoming lightning
// receive. The daemon does the actual claiming (it long-polls the mailbox and
// reveals the preimage); claiming a receive produces a lightning-receive
// movement, which surfaces here as a MovementCreated notification. This is
// event-driven — NextNotification blocks until something happens — so we no
// longer poll every few seconds.
func (bs *BarkService) runNotificationLoop(ctx context.Context) {
defer bs.loopWg.Done()
notifications := bs.wallet.Notifications()
defer notifications.Destroy()
// NextNotification blocks; CancelNextNotificationWait unblocks it (returning
// nil) so the loop can exit promptly on shutdown.
go func() {
<-ctx.Done()
notifications.CancelNextNotificationWait()
}()
for {
if ctx.Err() != nil {
return
}
notif, err := notifications.NextNotification()
if err != nil {
logger.Logger.WithError(err).Debug("Bark NextNotification failed")
// Back off briefly so a persistent error doesn't spin the loop.
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
if notif == nil {
// nil is returned when the wait was cancelled (shutdown) or the
// notification source was shut down permanently.
return
}
bs.handleNotification(*notif)
}
}
func (bs *BarkService) handleNotification(notif bark.WalletNotification) {
logger.Logger.WithFields(notificationLogFields(notif)).Debug("Received Bark notification")
var movement bark.Movement
switch n := notif.(type) {
case bark.WalletNotificationMovementCreated:
movement = n.Movement
case bark.WalletNotificationMovementUpdated:
movement = n.Movement
default:
// Channel lagging and other kinds carry no movement to act on.
return
}
switch {
case strings.Contains(movement.SubsystemName, lightningReceiveSubsystem):
bs.handleLightningReceiveMovement(movement)
case strings.Contains(movement.SubsystemName, lightningSendSubsystem):
bs.handleLightningSendMovement(movement)
}
}
func (bs *BarkService) handleLightningReceiveMovement(movement bark.Movement) {
// A receive is only credited once its movement settles. We always hold the
// preimage for our own receives, so PreimageRevealed isn't a useful signal;
// the balance is credited when the movement status reaches "successful".
if movement.Status != movementStatusSuccessful {
return
}
paymentHash, ok := paymentHashFromMovement(movement)
if !ok {
return
}
receive, err := bs.wallet.LightningReceiveStatus(paymentHash)
if err != nil || receive == nil {
logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Warn("Failed to look up claimed Bark receive")
return
}
tx, err := bs.lightningReceiveToTransaction(receive)
if err != nil {
logger.Logger.WithError(err).WithField("paymentHash", receive.PaymentHash).Warn("Failed to convert claimed Bark receive to transaction")
return
}
logger.Logger.WithFields(logrus.Fields{
"paymentHash": receive.PaymentHash,
"amountSats": receive.AmountSats,
}).Info("Bark lightning receive claimed")
bs.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_payment_received",
Properties: tx,
})
}
// handleLightningSendMovement delivers a terminal lightning_send outcome to
// the SendPaymentSync waiter for the matching payment_hash. If no waiter is
// registered (e.g. the hub was restarted mid-send and SendPaymentSync's
// goroutine is gone) it falls back to publishing nwc_lnclient_payment_sent /
// _failed so the transactions service can recover the db transaction state.
func (bs *BarkService) handleLightningSendMovement(movement bark.Movement) {
if movement.Status != movementStatusSuccessful && movement.Status != movementStatusFailed {
return
}
var meta struct {
PaymentHash string `json:"payment_hash"`
PaymentPreimage string `json:"payment_preimage"`
}
if err := json.Unmarshal([]byte(movement.MetadataJson), &meta); err != nil || meta.PaymentHash == "" {
logger.Logger.WithError(err).WithField("movementId", movement.Id).Debug("Bark lightning send movement missing payment_hash")
return
}
if movement.Status == movementStatusFailed {
bs.deliverSendResult(meta.PaymentHash, sendResult{err: errors.New("bark lightning send failed")}, func() {
bs.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_payment_failed",
Properties: &lnclient.PaymentFailedEventProperties{
Transaction: &lnclient.Transaction{
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: meta.PaymentHash,
},
Reason: "bark lightning send failed",
},
})
})
return
}
if meta.PaymentPreimage == "" {
logger.Logger.WithField("paymentHash", meta.PaymentHash).Error("Bark lightning send reported successful but preimage is missing from movement metadata")
bs.deliverSendResult(meta.PaymentHash, sendResult{err: errors.New("bark lightning send completed without a preimage")}, nil)
return
}
feeMsat := movement.OffchainFeeSats * 1000
logger.Logger.WithFields(logrus.Fields{
"paymentHash": meta.PaymentHash,
"feeMsat": feeMsat,
}).Info("Bark lightning send completed")
bs.deliverSendResult(meta.PaymentHash, sendResult{preimage: meta.PaymentPreimage, feeMsat: feeMsat}, func() {
settledAt := time.Now().Unix()
bs.eventPublisher.Publish(&events.Event{
Event: "nwc_lnclient_payment_sent",
Properties: &lnclient.Transaction{
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: meta.PaymentHash,
Preimage: meta.PaymentPreimage,
FeesPaidMsat: int64(feeMsat),
SettledAt: &settledAt,
},
})
})
}
// deliverSendResult delivers to the SendPaymentSync waiter if present, else
// runs fallback (used to publish an event for the hub-restart recovery path).
func (bs *BarkService) deliverSendResult(paymentHash string, res sendResult, fallback func()) {
if ch, ok := bs.takeInflightSend(paymentHash); ok {
ch <- res
return
}
if fallback != nil {
fallback()
}
}
func paymentHashFromMovement(movement bark.Movement) (string, bool) {
var meta struct {
PaymentHash string `json:"payment_hash"`
}
if err := json.Unmarshal([]byte(movement.MetadataJson), &meta); err != nil || meta.PaymentHash == "" {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"movementId": movement.Id,
"subsystemName": movement.SubsystemName,
}).Debug("Bark lightning movement missing payment_hash")
return "", false
}
return meta.PaymentHash, true
}
// notificationLogFields turns a Bark wallet notification into structured log
// fields describing its concrete type, rather than logging the raw interface
// pointer (which would just print an address).
func notificationLogFields(notif bark.WalletNotification) logrus.Fields {
switch n := notif.(type) {
case bark.WalletNotificationMovementCreated:
return movementLogFields("movement_created", n.Movement)
case bark.WalletNotificationMovementUpdated:
return movementLogFields("movement_updated", n.Movement)
case bark.WalletNotificationChannelLagging:
return logrus.Fields{"kind": "channel_lagging"}
default:
return logrus.Fields{"kind": fmt.Sprintf("%T", notif)}
}
}
func movementLogFields(kind string, m bark.Movement) logrus.Fields {
return logrus.Fields{
"kind": kind,
"movementId": m.Id,
"status": m.Status,
"subsystemName": m.SubsystemName,
"subsystemKind": m.SubsystemKind,
"metadataJson": m.MetadataJson,
"intendedBalanceSats": m.IntendedBalanceSats,
"effectiveBalanceSats": m.EffectiveBalanceSats,
"offchainFeeSats": m.OffchainFeeSats,
"sentToAddresses": m.SentToAddresses,
"receivedOnAddresses": m.ReceivedOnAddresses,
"inputVtxoIds": m.InputVtxoIds,
"outputVtxoIds": m.OutputVtxoIds,
"exitedVtxoIds": m.ExitedVtxoIds,
"createdAt": m.CreatedAt,
"updatedAt": m.UpdatedAt,
"completedAt": m.CompletedAt,
}
}
func (bs *BarkService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, throughNodePubkey *string) (*lnclient.Transaction, error) {
if amountMsat <= 0 {
return nil, errors.New("0-amount invoices not supported")
}
if amountMsat%1000 != 0 {
return nil, errors.New("amount must be a whole number of sats")
}
var desc *string
if description != "" {
desc = &description
}
invoice, err := bs.wallet.Bolt11Invoice(uint64(amountMsat/1000), desc)
if err != nil {
return nil, fmt.Errorf("bark Bolt11Invoice failed: %w", err)
}
paymentRequest, err := decodepay.Decodepay(invoice.Invoice)
if err != nil {
logger.Logger.WithError(err).WithField("bolt11", invoice.Invoice).Error("Failed to decode bark-generated bolt11 invoice")
return nil, err
}
expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
// The preimage is generated alongside the invoice but is not returned by
// Bolt11Invoice. Fetch it via the receive status so consumers can rely on
// lookup_invoice exposing the real preimage.
var preimage string
receive, err := bs.wallet.LightningReceiveStatus(paymentRequest.PaymentHash)
if err != nil {
logger.Logger.WithError(err).WithField("paymentHash", paymentRequest.PaymentHash).Error("Failed to fetch bark receive status for preimage")
return nil, err
}
preimage = receive.PaymentPreimage
if preimage == "" {
return nil, errors.New("no preimage available")
}
return &lnclient.Transaction{
Type: constants.TRANSACTION_TYPE_INCOMING,
Invoice: invoice.Invoice,
Preimage: preimage,
PaymentHash: paymentRequest.PaymentHash,
AmountMsat: amountMsat,
CreatedAt: int64(paymentRequest.CreatedAt),
ExpiresAt: &expiresAtUnix,
Description: paymentRequest.Description,
DescriptionHash: paymentRequest.DescriptionHash,
}, nil
}
func (bs *BarkService) SendPaymentSync(invoice string, amountMsat *uint64) (*lnclient.PayInvoiceResponse, error) {
// 0-amount invoices not supported initially — keeps the surface minimal.
if amountMsat != nil {
return nil, errors.New("0-amount invoices not supported")
}
paymentRequest, decodeErr := decodepay.Decodepay(invoice)
if decodeErr != nil {
return nil, fmt.Errorf("failed to decode invoice: %w", decodeErr)
}
paymentHash := paymentRequest.PaymentHash
// Register a waiter BEFORE initiating the send so a notification that
// arrives before this goroutine reaches the receive cannot be missed.
resultCh := make(chan sendResult, 1)
if err := bs.registerInflightSend(paymentHash, resultCh); err != nil {
return nil, err
}
defer bs.clearInflightSend(paymentHash)
if _, err := bs.wallet.PayLightningInvoice(invoice, nil); err != nil {
return nil, fmt.Errorf("bark PayLightningInvoice failed: %w", err)
}
// Block until handleLightningSendMovement delivers a terminal result.
res := <-resultCh
if res.err != nil {
return nil, res.err
}
return &lnclient.PayInvoiceResponse{
Preimage: res.preimage,
FeeMsat: res.feeMsat,
}, nil
}
func (bs *BarkService) registerInflightSend(paymentHash string, ch chan sendResult) error {
bs.inflightSendsMtx.Lock()
defer bs.inflightSendsMtx.Unlock()
if _, exists := bs.inflightSends[paymentHash]; exists {
return fmt.Errorf("a bark lightning send is already in flight for payment hash %s", paymentHash)
}
bs.inflightSends[paymentHash] = ch
return nil
}
func (bs *BarkService) clearInflightSend(paymentHash string) {
bs.inflightSendsMtx.Lock()
defer bs.inflightSendsMtx.Unlock()
delete(bs.inflightSends, paymentHash)
}
func (bs *BarkService) takeInflightSend(paymentHash string) (chan sendResult, bool) {
bs.inflightSendsMtx.Lock()
defer bs.inflightSendsMtx.Unlock()
ch, ok := bs.inflightSends[paymentHash]
if ok {
delete(bs.inflightSends, paymentHash)
}
return ch, ok
}
func (bs *BarkService) LookupInvoice(ctx context.Context, paymentHash string) (*lnclient.Transaction, error) {
return nil, errors.New("this method should not be called")
}
func (bs *BarkService) lightningReceiveToTransaction(receive *bark.LightningReceive) (*lnclient.Transaction, error) {
paymentRequest, err := decodepay.Decodepay(receive.Invoice)
if err != nil {
return nil, err
}
expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
tx := &lnclient.Transaction{
Type: constants.TRANSACTION_TYPE_INCOMING,
Invoice: receive.Invoice,
PaymentHash: receive.PaymentHash,
AmountMsat: paymentRequest.MSatoshi,
CreatedAt: int64(paymentRequest.CreatedAt),
ExpiresAt: &expiresAtUnix,
Description: paymentRequest.Description,
DescriptionHash: paymentRequest.DescriptionHash,
}
if receive.PreimageRevealed {
now := time.Now().Unix()
tx.SettledAt = &now
tx.Preimage = receive.PaymentPreimage
}
return tx, nil
}
func (bs *BarkService) GetBalances(ctx context.Context, includeInactiveChannels bool) (*lnclient.BalancesResponse, error) {
balance, err := bs.wallet.Balance()
if err != nil {
return nil, err
}
spendableMsat := int64(balance.SpendableSats) * 1000
return &lnclient.BalancesResponse{
Onchain: lnclient.OnchainBalanceResponse{
PendingBalancesDetails: []lnclient.PendingBalanceDetails{},
PendingSweepBalancesDetails: []lnclient.PendingBalanceDetails{},
},
Lightning: lnclient.LightningBalanceResponse{
TotalSpendableMsat: spendableMsat,
NextMaxSpendableMsat: spendableMsat,
NextMaxSpendableMPPMsat: spendableMsat,
},
}, nil
}
func (bs *BarkService) GetInfo(ctx context.Context) (*lnclient.NodeInfo, error) {
return &lnclient.NodeInfo{
Alias: "Bark",
Color: "#897FFF",
Pubkey: bs.pubkey,
Network: bs.network,
}, nil
}
func (bs *BarkService) GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) {
return &lnclient.NodeStatus{
IsReady: true,
}, nil
}
func (bs *BarkService) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) {
return &lnclient.NodeConnectionInfo{
Pubkey: bs.pubkey,
}, nil
}
func (bs *BarkService) GetPubkey() string {
return bs.pubkey
}
func (bs *BarkService) GetSupportedNIP47Methods() []string {
return []string{"pay_invoice", "get_balance", "get_budget", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice"}
}
func (bs *BarkService) GetSupportedNIP47NotificationTypes() []string {
// payment_received is emitted from runNotificationLoop when the daemon
// claims an incoming receive; payment_sent is emitted by the transactions
// service when our synchronous SendPaymentSync succeeds.
return []string{
notifications.PAYMENT_RECEIVED_NOTIFICATION,
notifications.PAYMENT_SENT_NOTIFICATION,
}
}
func (bs *BarkService) Shutdown() error {
if bs.cancelFn != nil {
bs.cancelFn()
done := make(chan struct{})
go func() {
bs.loopWg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(shutdownGracePeriod):
logger.Logger.Warn("Timed out waiting for Bark background loops to stop")
}
}
if err := bs.wallet.StopDaemon(); err != nil {
logger.Logger.WithError(err).Warn("Bark StopDaemon failed")
}
bs.wallet.Destroy()
return nil
}
// --- unsupported / stubbed methods ---
func (bs *BarkService) SendKeysend(amountMsat uint64, destination string, customRecords []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) {
return nil, errors.New("keysend not supported")
}
func (bs *BarkService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expiry int64, paymentHash string, minCltvExpiryDelta *uint64) (*lnclient.Transaction, error) {
return nil, errors.New("not implemented")
}
func (bs *BarkService) SettleHoldInvoice(ctx context.Context, preimage string) error {
return errors.New("not implemented")
}
func (bs *BarkService) CancelHoldInvoice(ctx context.Context, paymentHash string) error {
return errors.New("not implemented")
}
func (bs *BarkService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) {
return []lnclient.Channel{}, nil
}
func (bs *BarkService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error {
return nil
}
func (bs *BarkService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) {
return nil, nil
}
func (bs *BarkService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) error {
return nil
}
func (bs *BarkService) UpdateChannel(ctx context.Context, updateChannelRequest *lnclient.UpdateChannelRequest) error {
return nil
}
func (bs *BarkService) DisconnectPeer(ctx context.Context, peerId string) error {
return nil
}
func (bs *BarkService) GetNewOnchainAddress(ctx context.Context) (string, error) {
return "", errors.New("not implemented")
}
func (bs *BarkService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) {
return &lnclient.OnchainBalanceResponse{}, nil
}
func (bs *BarkService) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (string, error) {
return "", errors.New("not implemented")
}
func (bs *BarkService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
return nil, nil
}
func (bs *BarkService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) {
return nil, nil
}
func (bs *BarkService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) {
return []byte{}, nil
}
func (bs *BarkService) SignMessage(ctx context.Context, message string) (string, error) {
return "", errors.New("not implemented")
}
func (bs *BarkService) GetStorageDir() (string, error) {
return bs.workDir, nil
}
func (bs *BarkService) ResetRouter(key string) error {
return errors.New("not implemented")
}
func (bs *BarkService) UpdateLastWalletSyncRequest() {}
func (bs *BarkService) MakeOffer(ctx context.Context, description string) (string, error) {
return "", errors.New("not supported")
}
func (bs *BarkService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
return nil, errors.ErrUnsupported
}
const (
nodeCommandDebug = "debug"
nodeCommandClaimLightningReceives = "claimlightningreceives"
nodeCommandRunMaintenance = "runmaintenance"
)
func (bs *BarkService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef {
return []lnclient.CustomNodeCommandDef{
{
Name: nodeCommandDebug,
Description: "Dump the wallet's balance breakdown, VTXOs, pending lightning receives, movement history and Ark server info. Useful for debugging a receive that did not credit your balance.",
Args: nil,
},
{
Name: nodeCommandClaimLightningReceives,
Description: "Attempt to claim any pending/unclaimed lightning receives. Use this if an invoice was paid but the funds have not shown up in your balance.",
Args: nil,
},
{
Name: nodeCommandRunMaintenance,
Description: "Run wallet maintenance, which progresses pending rounds and refreshes VTXOs. Use this to nudge funds that are stuck 'pending in round'.",
Args: nil,
},
}
}
func (bs *BarkService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
switch command.Name {
case nodeCommandDebug:
return bs.executeCommandDebug()
case nodeCommandClaimLightningReceives:
return bs.executeCommandClaimLightningReceives()
case nodeCommandRunMaintenance:
return bs.executeCommandRunMaintenance()
}
return nil, lnclient.ErrUnknownCustomNodeCommand
}
func (bs *BarkService) executeCommandDebug() (*lnclient.CustomNodeCommandResponse, error) {
// Sync first so we report current state rather than a stale snapshot (the
// same pattern GetBalances uses before reading the balance).
if err := bs.wallet.Sync(); err != nil {
logger.Logger.WithError(err).Warn("Bark sync failed before collecting debug info")
}
response := map[string]interface{}{
"network": bs.network,
"pubkey": bs.pubkey,
}
if balance, err := bs.wallet.Balance(); err != nil {
response["balanceError"] = err.Error()
} else {
response["balance"] = balance
}
if claimable, err := bs.wallet.ClaimableLightningReceiveBalanceSats(); err != nil {
response["claimableLightningReceiveSatsError"] = err.Error()
} else {
response["claimableLightningReceiveSats"] = claimable
}
if vtxos, err := bs.wallet.Vtxos(); err != nil {
response["vtxosError"] = err.Error()
} else {
response["vtxos"] = vtxos
}
if spendable, err := bs.wallet.SpendableVtxos(); err != nil {
response["spendableVtxosError"] = err.Error()
} else {
response["spendableVtxos"] = spendable
}
if pending, err := bs.wallet.PendingLightningReceives(); err != nil {
response["pendingLightningReceivesError"] = err.Error()
} else {
response["pendingLightningReceives"] = pending
}
if history, err := bs.wallet.History(); err != nil {
response["historyError"] = err.Error()
} else {
response["history"] = history
}
// Round state explains funds stuck in PendingInRoundSats: such funds sit in a
// round whose funding tx is waiting for confirmations (6 on mainnet), which
// the daemon progresses automatically once confirmed.
if rounds, err := bs.wallet.PendingRoundStates(); err != nil {
response["pendingRoundStatesError"] = err.Error()
} else {
response["pendingRoundStates"] = rounds
}
if nextRoundStartTime, err := bs.wallet.NextRoundStartTime(); err != nil {
response["nextRoundStartTimeError"] = err.Error()
} else {
response["nextRoundStartTime"] = nextRoundStartTime
}
if arkInfo := bs.wallet.ArkInfo(); arkInfo != nil {
response["arkInfo"] = arkInfo
}
return &lnclient.CustomNodeCommandResponse{
Response: response,
}, nil
}
func (bs *BarkService) executeCommandRunMaintenance() (*lnclient.CustomNodeCommandResponse, error) {
if err := bs.wallet.Maintenance(); err != nil {
return nil, fmt.Errorf("failed to run maintenance: %w", err)
}
logger.Logger.Debug("Ran Bark maintenance")
balance, err := bs.wallet.Balance()
if err != nil {
return nil, fmt.Errorf("maintenance succeeded but failed to read balance: %w", err)
}
return &lnclient.CustomNodeCommandResponse{
Response: map[string]interface{}{
"message": "Maintenance completed.",
"balance": balance,
},
}, nil
}
func (bs *BarkService) executeCommandClaimLightningReceives() (*lnclient.CustomNodeCommandResponse, error) {
if err := bs.wallet.Sync(); err != nil {
logger.Logger.WithError(err).Warn("Bark sync failed before claiming lightning receives")
}
// wait=false: attempt to claim what is already claimable without blocking on
// the server long-polling for not-yet-arrived payments.
claimed, err := bs.wallet.TryClaimAllLightningReceives(false)
if err != nil {
return nil, fmt.Errorf("failed to claim lightning receives: %w", err)
}
logger.Logger.WithField("count", len(claimed)).Info("Attempted to claim Bark lightning receives")
return &lnclient.CustomNodeCommandResponse{
Response: map[string]interface{}{
"claimedCount": len(claimed),
"claimed": claimed,
},
}, nil
}

View file

@ -21,6 +21,7 @@ import (
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/lnclient/bark"
"github.com/getAlby/hub/lnclient/cashu"
"github.com/getAlby/hub/lnclient/cln"
"github.com/getAlby/hub/lnclient/ldk"
@ -373,6 +374,17 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e
cashuWorkdir := path.Join(svc.cfg.GetEnv().Workdir, "cashu")
lnClient, err = cashu.NewCashuService(svc.cfg, cashuWorkdir, mnemonic, cashuMintUrl)
case config.BarkBackendType:
mnemonic, _ := svc.cfg.Get("Mnemonic", encryptionKey)
env := svc.cfg.GetEnv()
barkWorkdir := path.Join(env.Workdir, "bark")
lnClient, err = bark.NewBarkService(ctx, svc.eventPublisher, barkWorkdir, mnemonic, bark.Config{
Network: svc.cfg.GetNetwork(),
ServerAddress: env.BarkServer,
EsploraAddress: env.BarkEsploraServer,
ServerAccessToken: env.BarkServerAccessToken,
})
case config.CLNBackendType:
CLNAddress, _ := svc.cfg.Get("CLNAddress", encryptionKey)
CLNLightningDir, _ := svc.cfg.Get("CLNLightningDir", encryptionKey)

View file

@ -67,6 +67,11 @@ func TestSendPaymentSync_SelfPaymentDetection_WithoutIncomingTransaction(t *test
assert.Equal(t, uint64(123000), transaction.AmountMsat)
}
// Self-payment detection is based solely on having an incoming transaction for
// the same invoice, not on the invoice payee matching our node pubkey. A
// mismatching pubkey must not prevent detection: some backends (e.g. Bark)
// don't own the node behind the invoice, and the payee is unavailable for
// private BOLT12 payments.
func TestSendPaymentSync_SelfPaymentDetection_DifferentPubkey(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
@ -89,6 +94,6 @@ func TestSendPaymentSync_SelfPaymentDetection_DifferentPubkey(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, transaction)
assert.False(t, transaction.SelfPayment)
assert.True(t, transaction.SelfPayment)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, transaction.State)
}

View file

@ -301,16 +301,16 @@ func (svc *transactionsService) SendPaymentSync(payReq string, amountMsat *uint6
return nil, errors.New("this invoice has expired")
}
// A payment is a self-payment if we have an incoming transaction for the
// exact same invoice (i.e. it was generated by this hub).
selfPayment := false
if paymentRequest.Payee != "" && paymentRequest.Payee == lnClient.GetPubkey() {
var incomingTransaction db.Transaction
result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: payReq,
})
if result.Error == nil && result.RowsAffected > 0 {
selfPayment = true
}
var incomingTransaction db.Transaction
result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: payReq,
})
if result.Error == nil && result.RowsAffected > 0 {
selfPayment = true
}
var dbTransaction db.Transaction