ADD: arkade ln pushes (#8634)
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions

This commit is contained in:
Overtorment 2026-06-10 17:35:17 +01:00 committed by GitHub
parent f334b985e8
commit 0181f0a849
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 226 additions and 56 deletions

View File

@ -53,6 +53,7 @@ jobs:
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}
@ -83,6 +84,7 @@ jobs:
BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}}
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }}
HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }}
HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }}
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }}

View File

@ -2,4 +2,7 @@
* Let's keep config vars, constants and definitions here
*/
export const groundControlUri: string = 'https://groundcontrol.bluewallet.io/';
export const groundControlUri: string = 'https://groundcontrol.bluewallet.io';
/** bitcoin-payment-push-service base URL, no trailing slash. Empty = disabled. */
export const arkadePaymentPushUri: string = 'https://electrum2.bluewallet.io:444';

View File

@ -8,8 +8,9 @@ import {
Notifications,
} from 'react-native-notifications';
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
import type { BoltzReverseSwap } from '@arkade-os/boltz-swap';
import loc from '../loc';
import { groundControlUri } from './constants';
import { arkadePaymentPushUri, groundControlUri } from './constants';
import { fetch } from '../util/fetch';
const PUSH_TOKEN = 'PUSH_TOKEN';
@ -251,6 +252,29 @@ export const tryToObtainPermissions = async (): Promise<boolean> => {
return false;
}
};
export const enqueueTestPushNotification = async (): Promise<void> => {
const pushToken = await getPushToken();
if (!pushToken?.token || !pushToken?.os) {
throw new Error('No push token available');
}
const response = await fetch(`${baseURI}/enqueue`, {
method: 'POST',
headers: _getHeaders(),
body: JSON.stringify({
type: 5,
token: pushToken.token,
os: pushToken.os,
text: 'Test push notification',
}),
});
if (!response.ok) {
throw new Error(`Enqueue request failed with status ${response.status}: ${response.statusText}`);
}
};
/**
* Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could
* be notified if they were paid
@ -326,6 +350,44 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
}
};
/**
* Registers an Ark swap with the bitcoin-payment-push-service so the device is
* pushed when the invoice gets paid. Fire-and-forget: never throws, gated by
* the same opt-out/token rules as majorTomToGroundControl(). The swap's
* preimage is always stripped before leaving the device.
*/
export const registerArkPaymentPush = async (paymentHash: string, label: string, pendingSwap: BoltzReverseSwap): Promise<void> => {
if (!arkadePaymentPushUri) return;
try {
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
if (noAndDontAskFlag === 'true') {
console.warn('User has opted out of notifications.');
return;
}
const pushToken = await getPushToken();
if (!pushToken || !pushToken.token || !pushToken.os) {
return;
}
const response = await fetch(`${arkadePaymentPushUri}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: paymentHash,
label,
swap: { ...pendingSwap, preimage: '' },
}),
});
if (!response.ok) {
throw new Error(`status ${response.status}`);
}
console.log('[ARK] payment push registration ok');
} catch (e: any) {
console.log('[ARK] payment push registration failed:', e?.message ?? e);
}
};
/**
* Returns a permissions object:
* alert: boolean

View File

@ -29,6 +29,7 @@ import assert from 'assert';
import ecc from '../../blue_modules/noble_ecc.ts';
import { Measure } from '../measure.ts';
import { deleteArkadeRealm, getArkadeRealm } from '../../blue_modules/arkade-adapters/realm/realmInstance';
import { registerArkPaymentPush } from '../../blue_modules/notifications';
const { bech32m } = require('bech32');
const bip32 = BIP32Factory(ecc);
@ -710,6 +711,8 @@ export class LightningArkWallet extends LightningCustodianWallet {
console.log('Pending swap', result.pendingSwap);
console.log('Preimage', result.preimage);
registerArkPaymentPush(result.paymentHash, memo, result.pendingSwap); // fire-and-forget, never throws
return result.invoice;
}

View File

@ -91,7 +91,7 @@
"lint": " npm run tslint && node scripts/find-unused-loc.js && node scripts/find-english-leftovers.js && eslint --ext .js,.ts,.tsx '*.@(js|ts|tsx)' screen 'blue_modules/*.@(js|ts|tsx)' class models loc tests components navigation typings",
"lint:fix": "npm run lint -- --fix",
"lint:quickfix": "git status --porcelain | grep -v '\\.json' | grep -E '\\.js|\\.ts' --color=never | awk '{print $2}' | xargs eslint --fix; exit 0",
"unit": "jest -b -w tests/unit/*"
"unit": "jest -b tests/unit/*"
},
"dependencies": {
"@arkade-os/boltz-swap": "0.3.37",

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Linking, StyleSheet, View, Pressable, AppState, Text } from 'react-native';
import { StyleSheet, View, Pressable, AppState, Text } from 'react-native';
import {
getPushToken,
getStoredNotifications,
@ -9,9 +9,12 @@ import {
cleanUserOptOutFlag,
checkPermissions,
checkNotificationPermissionStatus,
enqueueTestPushNotification,
NOTIFICATIONS_NO_AND_DONT_ASK_FLAG,
} from '../../blue_modules/notifications';
import presentAlert from '../../components/Alert';
import { BlueSpacing20 } from '../../components/BlueSpacing';
import { Button } from '../../components/Button';
import CopyToClipboardButton from '../../components/CopyToClipboardButton';
import { useTheme } from '../../components/themes';
import loc from '../../loc';
@ -164,6 +167,18 @@ const NotificationSettings: React.FC = () => {
};
}, []);
const enqueueTestPush = useCallback(async () => {
setIsLoading(true);
try {
await enqueueTestPushNotification();
} catch (error) {
console.error('Error enqueueing test push:', error);
presentAlert({ message: (error as Error).message });
} finally {
setIsLoading(false);
}
}, []);
const renderDeveloperSettings = useCallback(() => {
if (tapCount < 10) return null;
@ -171,15 +186,6 @@ const NotificationSettings: React.FC = () => {
<View>
<View style={[styles.divider, { backgroundColor: colors.lightBorder ?? colors.borderTopColor }]} />
<SettingsListItem
title="github.com/BlueWallet/GroundControl"
iconName="github"
onPress={() => Linking.openURL('https://github.com/BlueWallet/GroundControl')}
chevron
position="single"
spacingTop
/>
<SettingsCard style={styles.card}>
<View style={styles.cardContent}>
<Text style={[styles.centered, { color: colors.foregroundColor }]} onPress={() => setTapCount(tapCount + 1)}>
@ -192,11 +198,14 @@ const NotificationSettings: React.FC = () => {
<View>
<CopyToClipboardButton stringToCopy={tokenInfo} displayText={tokenInfo} />
</View>
<BlueSpacing20 />
<Button onPress={enqueueTestPush} title="Enqueue test push notification" disabled={isLoading} />
</View>
</SettingsCard>
</View>
);
}, [tapCount, colors, tokenInfo]);
}, [tapCount, colors, isLoading, tokenInfo, enqueueTestPush]);
const renderPushNotificationsExplanation = useCallback(() => {
return (

View File

@ -19,7 +19,7 @@
*/
import { closeAllArkadeRealms, __testing__ as realmTesting } from '../../blue_modules/arkade-adapters/realm/realmInstance';
import { __testing__ as walletTesting } from '../../class/wallets/lightning-ark-wallet';
import { LightningArkWallet, __testing__ as walletTesting } from '../../class/wallets/lightning-ark-wallet';
const Realm = require('realm');
@ -81,3 +81,39 @@ export const arkadeMockState = {
Keychain.__mockKeychainHelpers.store.set(service, { username: service, password, service });
},
};
/**
* Tear down a LightningArkWallet after integration tests. Stops SDK background
* loops (ContractWatcher SSE, VtxoManager polling, SwapManager) via dispose()
* before clearing module-private caches.
*/
export async function teardownArkadeWallet(w: LightningArkWallet): Promise<void> {
try {
await w.onDelete();
} catch {
// onDelete already logs and swallows per-namespace errors.
}
}
/** Best-effort dispose of any Arkade SDK runtime still cached module-wide. */
export async function disposeAllArkadeRuntime(): Promise<void> {
for (const ns of Object.keys(walletTesting.staticSwapsCache)) {
const swaps = walletTesting.staticSwapsCache[ns];
try {
if (typeof swaps?.dispose === 'function') await swaps.dispose();
} catch {}
delete walletTesting.staticSwapsCache[ns];
}
for (const ns of Object.keys(walletTesting.staticWalletCache)) {
const sdkWallet = walletTesting.staticWalletCache[ns];
try {
if (typeof sdkWallet?.dispose === 'function') await sdkWallet.dispose();
} catch {}
delete walletTesting.staticWalletCache[ns];
}
walletTesting.initInFlight.clear();
walletTesting.restoreInFlight.clear();
for (const k of Object.keys(walletTesting.boardingLock)) delete walletTesting.boardingLock[k];
closeAllArkadeRealms();
realmTesting.openInFlight.clear();
}

View File

@ -110,3 +110,25 @@ export function installSdkProviderSpies(): void {
export function restoreSdkProviderSpies(): void {
jest.restoreAllMocks();
}
let backgroundLoopSpies: jest.SpiedFunction<any>[] = [];
export function restoreSdkBackgroundLoopStubs(): void {
for (const spy of backgroundLoopSpies) spy.mockRestore();
backgroundLoopSpies = [];
}
/**
* Stub only the SDK background subscriptions that Jest cannot shut down
* cleanly (VtxoManager polling, SwapManager WebSocket, ContractWatcher SSE).
* Real HTTP calls (getInfo, getTransactionHistory, restoreSwaps, etc.) still
* run use in env-gated integration tests that hit production services.
*/
export function installSdkBackgroundLoopStubs(): void {
restoreSdkBackgroundLoopStubs();
backgroundLoopSpies = [
jest.spyOn(VtxoManager.prototype as any, 'initializeSubscription').mockResolvedValue(undefined),
jest.spyOn(SwapManager.prototype as any, 'start').mockResolvedValue(undefined),
jest.spyOn(ContractManager.prototype as any, 'initialize').mockResolvedValue(undefined),
];
}

View File

@ -2,6 +2,8 @@ import assert from 'assert';
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet.ts';
import { disposeAllArkadeRuntime, teardownArkadeWallet } from '../helpers/arkadeMocks';
import { installSdkBackgroundLoopStubs, restoreSdkBackgroundLoopStubs } from '../helpers/sdkProviderMocks';
// Ark storage lives in Realm, not AsyncStorage. Realm + Keychain are mocked
// globally by tests/setup.js (per-path Realm + service-keyed Keychain), and
@ -15,29 +17,41 @@ jest.setTimeout(30_000);
const w = new LightningArkWallet();
beforeAll(async () => {
// Install before the env guard: `can generate` runs init() regardless of
// HD_MNEMONIC_OLD, and without the stubs its background loops keep Jest alive.
installSdkBackgroundLoopStubs();
if (!process.env.HD_MNEMONIC_OLD) {
console.error('process.env.HD_MNEMONIC_OLD not set, skipped');
return;
}
w.setSecret('arkade://' + process.env.HD_MNEMONIC_OLD);
await w.init();
await w.restoreSwaps();
});
afterAll(async () => {
await new Promise(resolve => setTimeout(resolve, 3_000)); // sleep
if (process.env.HD_MNEMONIC_OLD) {
await teardownArkadeWallet(w);
}
await disposeAllArkadeRuntime();
restoreSdkBackgroundLoopStubs();
});
describe('LightningArkWallet (integration)', () => {
it('can generate', async () => {
const wGenerated = new LightningArkWallet();
await wGenerated.generate();
try {
await wGenerated.generate();
assert.ok(wGenerated.getSecret().startsWith('arkade://'));
assert.ok(wGenerated.getSecret().startsWith('arkade://'));
const mnemonics = wGenerated.getSecret().replace('arkade://', '');
const hd = new HDSegwitBech32Wallet();
hd.setSecret(mnemonics);
assert.ok(hd.validateMnemonic());
const mnemonics = wGenerated.getSecret().replace('arkade://', '');
const hd = new HDSegwitBech32Wallet();
hd.setSecret(mnemonics);
assert.ok(hd.validateMnemonic());
} finally {
await teardownArkadeWallet(wGenerated);
}
});
it('can fetch balance', async () => {
@ -70,47 +84,48 @@ describe('LightningArkWallet (integration)', () => {
}
await w.fetchTransactions();
await w.fetchUserInvoices();
const txs = w.getTransactions();
assert.ok(txs.length > 0);
assert.ok(txs.length > 0, 'Should have transaction history from the Ark indexer');
// Find the reverse swap (incoming) transaction
const receiveTx = txs.find(t => t.value! > 0);
assert.ok(receiveTx, 'Should have at least one receive transaction');
assert.strictEqual(receiveTx.memo, 'test invoice');
assert.strictEqual(receiveTx.value, 9999);
assert.strictEqual(receiveTx.timestamp, 1761224952);
assert.strictEqual(receiveTx.ispaid, true);
assert.ok(receiveTx.payment_hash);
assert.ok(receiveTx.payment_request);
assert.strictEqual(receiveTx.payment_preimage, '7244f7e956a91171038ea935d56cdb758cc36c345f0aa92764bfed6fe6fc9b17');
assert.ok(receiveTx.value! > 0);
assert.ok(receiveTx.timestamp! > 0);
assert.ok(receiveTx.memo);
// Find the submarine swap (outgoing) transaction
const sendTx = txs.find(t => t.value! < 0);
assert.ok(sendTx, 'Should have at least one send transaction');
assert.strictEqual(sendTx.value, -8001);
assert.strictEqual(sendTx.timestamp, 1761225645);
assert.strictEqual(sendTx.ispaid, true);
assert.ok(sendTx.payment_hash);
assert.ok(sendTx.payment_request);
assert.strictEqual(sendTx.payment_preimage, '182fb8f273bda01b22c0e91991e093e18b2970f389fc7f7a2121870324eb2de5');
const swapHistory: any[] = (w as any)._swapHistory ?? [];
const settledReverse = swapHistory.find(s => s.type === 'reverse' && s.status === 'invoice.settled');
if (settledReverse) {
// When Boltz reverse-swap history is restored, settled receives are enriched in place.
assert.strictEqual(receiveTx.ispaid, true);
assert.ok(receiveTx.payment_hash);
assert.ok(receiveTx.payment_request);
assert.ok(receiveTx.payment_preimage);
assert.notStrictEqual(receiveTx.memo, 'Received');
const ownInvoice = settledReverse.request?.invoice || settledReverse.response?.invoice;
if (ownInvoice) {
assert.ok(w.isInvoiceGeneratedByWallet(ownInvoice));
}
}
const settledSubmarine = swapHistory.find(s => s.type === 'submarine' && s.status === 'transaction.claimed');
if (settledSubmarine) {
const sendTx = txs.find(t => t.value! < 0);
assert.ok(sendTx, 'Should have a send transaction when submarine swap history exists');
assert.strictEqual(sendTx.ispaid, true);
assert.ok(sendTx.payment_hash);
assert.ok(sendTx.payment_request);
assert.ok(sendTx.payment_preimage);
}
const invoices = await w.getUserInvoices();
assert.ok(invoices.length > 0);
assert(invoices[0].value! > 0);
assert(invoices[0].ispaid);
assert.ok(
w.isInvoiceGeneratedByWallet(
'lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m',
),
);
assert.ok(
!w.isInvoiceGeneratedByWallet(
'lnbc80u1p5052hwpp5z4ln6hyq4wcck809pt7f0q54ag5he6ce797flm7gl9vuccm9lx2sdqqcqzysxqyz5vqsp5nh9fl4g36606tvxswtnfxzy55yze2656cw2fya7dhl8r6u0czyds9qxpqysgq83sw25g9d9ltr05nkfzejnvvunzkrk4qeuxhszuvvsguk5m6vmg3a7n5nd67l9frru3kjzpt8x6jfusjyc7ezh49jeeh900kt3v30qsqzq7fst',
),
);
if (settledReverse) {
assert.ok(invoices.length > 0);
assert(invoices[0].value! > 0);
assert(invoices[0].ispaid);
}
});
// eslint-disable-next-line jest/no-disabled-tests

View File

@ -7,7 +7,8 @@ console.warn = (...args) => {
if (
typeof args[0] === 'string' &&
(args[0].startsWith('WARNING: Sending to a future segwit version address can lead to loss of funds') ||
args[0].startsWith('only compressed public keys are good'))
args[0].startsWith('only compressed public keys are good') ||
args[0].startsWith('Using standard fetch instead of expo/fetch'))
) {
return;
}
@ -510,6 +511,17 @@ jest.mock('../blue_modules/analytics', () => {
return ret;
});
// addInvoice() registers a fire-and-forget payment-push callback; disable the
// URI in unit tests so node-fetch does not leave in-flight handles after the
// suite exits (which makes Jest fail with "did not exit one second after").
jest.mock('../blue_modules/constants', () => {
const actual = jest.requireActual('../blue_modules/constants');
return {
...actual,
arkadePaymentPushUri: '',
};
});
jest.mock('react-native-share', () => {
return {
open: jest.fn(),

View File

@ -1,8 +1,14 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { _setSkipUpdateExchangeRate } from '../../blue_modules/currency';
import TransactionStatus from '../../screen/transactions/TransactionStatus';
// TransactionStatus renders fiat amounts via satoshiToLocalCurrency(), which
// kicks off a real exchange-rate fetch when no rate is cached — leaving a TLS
// socket open after the run ("Jest did not exit one second after...").
_setSkipUpdateExchangeRate();
type MockStorage = {
wallets: any[];
txMetadata: Record<string, any>;