fix(LNC): route register callbacks through events to avoid new-arch crash

This commit is contained in:
Evan Kaloudis
2026-07-27 10:54:33 -04:00
parent e36862e3f9
commit 4f36cad55c
11 changed files with 179 additions and 35 deletions
@@ -5,10 +5,17 @@ import com.facebook.react.bridge.Callback
class AndroidCallback: NativeCallback {
protected lateinit var rnCallback: Callback
private val consumed = java.util.concurrent.atomic.AtomicBoolean(false)
// Under React Native's new architecture, Callback can only be invoked once.
// The Go LNC bridge may legitimately fire callbacks more than once for some
// routes (potentially from different threads); the atomic compare-and-set
// drops subsequent invocations safely to avoid a fatal abort.
override fun sendResult(data: String) {
if (::rnCallback.isInitialized && consumed.compareAndSet(false, true)) {
rnCallback.invoke(data)
}
}
fun setCallback(callback: Callback) {
rnCallback = callback
@@ -35,25 +35,28 @@ class LncModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMod
}
@ReactMethod
fun registerLocalPrivCreateCallback(namespace: String, onLocalPrivCreate: Callback) {
val lpccb = AndroidCallback()
lpccb.setCallback(onLocalPrivCreate)
fun registerLocalPrivCreateCallback(namespace: String, eventName: String) {
val lpccb = AndroidStreamingCallback()
lpccb.setEventName(eventName)
lpccb.setCallback(::sendEvent)
Lndmobile.registerLocalPrivCreateCallback(namespace, lpccb)
}
@ReactMethod
fun registerRemoteKeyReceiveCallback(namespace: String, onRemoteKeyReceive: Callback) {
val rkrcb = AndroidCallback()
rkrcb.setCallback(onRemoteKeyReceive)
fun registerRemoteKeyReceiveCallback(namespace: String, eventName: String) {
val rkrcb = AndroidStreamingCallback()
rkrcb.setEventName(eventName)
rkrcb.setCallback(::sendEvent)
Lndmobile.registerRemoteKeyReceiveCallback(namespace, rkrcb)
}
@ReactMethod
fun registerAuthDataCallback(namespace: String, onAuthData: Callback) {
val oacb = AndroidCallback()
oacb.setCallback(onAuthData)
fun registerAuthDataCallback(namespace: String, eventName: String) {
val oacb = AndroidStreamingCallback()
oacb.setEventName(eventName)
oacb.setCallback(::sendEvent)
Lndmobile.registerAuthDataCallback(namespace, oacb)
}
+25 -2
View File
@@ -2,15 +2,38 @@
#import "Callback.h"
#import <React/RCTEventEmitter.h>
#import <Foundation/Foundation.h>
#include <atomic>
@implementation Callback
@implementation Callback {
std::atomic<bool> _consumed;
}
-(instancetype)init {
if ((self = [super init])) {
_consumed.store(false, std::memory_order_relaxed);
}
return self;
}
-(void)setCallback:(RCTResponseSenderBlock)callback {
self.rnCallback = callback;
}
-(void)sendResult:(NSString *)data {
self.rnCallback(@[data]);
// Under React Native's new architecture, RCTResponseSenderBlock can only be
// invoked once. The Go LNC bridge may legitimately fire callbacks more than
// once for some routes (potentially from different threads); the atomic
// compare-exchange drops subsequent invocations safely to avoid a fatal abort.
bool expected = false;
if (!_consumed.compare_exchange_strong(expected, true)) {
return;
}
RCTResponseSenderBlock cb = self.rnCallback;
if (cb == nil) {
return;
}
// Defend against a nil data argument — @[nil] would throw NSInvalidArgumentException.
cb(@[data ?: @""]);
}
@end
+15 -9
View File
@@ -21,10 +21,11 @@ RCT_EXPORT_METHOD(initLNC:(NSString *)nameSpace)
}
RCT_EXPORT_METHOD(registerLocalPrivCreateCallback:(NSString *)nameSpace
resolver:(RCTResponseSenderBlock)onLocalPrivCreate)
eventName:(NSString *)eventName)
{
Callback *lpccb = [[Callback alloc] init];
[lpccb setCallback:onLocalPrivCreate];
StreamingCallback *lpccb = [[StreamingCallback alloc] init];
lpccb.delegate = self;
[lpccb setEventName:eventName];
NSError *error;
LndmobileRegisterLocalPrivCreateCallback(nameSpace, lpccb, &error);
if (error) {
@@ -33,10 +34,11 @@ RCT_EXPORT_METHOD(registerLocalPrivCreateCallback:(NSString *)nameSpace
}
RCT_EXPORT_METHOD(registerRemoteKeyReceiveCallback:(NSString *)nameSpace
resolver:(RCTResponseSenderBlock)onRemoteKeyReceive)
eventName:(NSString *)eventName)
{
Callback * rkrcb = [[Callback alloc] init];
[rkrcb setCallback:onRemoteKeyReceive];
StreamingCallback *rkrcb = [[StreamingCallback alloc] init];
rkrcb.delegate = self;
[rkrcb setEventName:eventName];
NSError *error;
LndmobileRegisterRemoteKeyReceiveCallback(nameSpace, rkrcb, &error);
if (error) {
@@ -45,10 +47,11 @@ RCT_EXPORT_METHOD(registerRemoteKeyReceiveCallback:(NSString *)nameSpace
}
RCT_EXPORT_METHOD(registerAuthDataCallback:(NSString *)nameSpace
resolver:(RCTResponseSenderBlock)onAuthData)
eventName:(NSString *)eventName)
{
Callback * oacb = [[Callback alloc] init];
[oacb setCallback:onAuthData];
StreamingCallback *oacb = [[StreamingCallback alloc] init];
oacb.delegate = self;
[oacb setEventName:eventName];
NSError *error;
LndmobileRegisterAuthDataCallback(nameSpace, oacb, &error);
if (error) {
@@ -187,6 +190,9 @@ RCT_EXPORT_METHOD(initListener:(NSString *)nameSpace
- (NSArray<NSString *> *)supportedEvents {
return @[
@"lnc.localPrivCreate",
@"lnc.remoteKeyReceive",
@"lnc.authData",
@"chainrpc.ChainNotifier.RegisterBlockEpochNtfn",
@"chainrpc.ChainNotifier.RegisterConfirmationsNtfn",
@"chainrpc.ChainNotifier.RegisterSpendNtfn",
+32 -3
View File
@@ -18,11 +18,19 @@ const DEFAULT_CONFIG = {
namespace: 'default',
serverHost: 'mailbox.terminal.lightning.today:443'
};
// Native event names emitted by LncModule for the persistent register callbacks.
// Kept in sync with ios/LncMobile/LncModule.mm and android/.../LncModule.kt.
const EVENT_LOCAL_PRIV_CREATE = 'lnc.localPrivCreate';
const EVENT_REMOTE_KEY_RECEIVE = 'lnc.remoteKeyReceive';
const EVENT_AUTH_DATA = 'lnc.authData';
class LNC {
constructor(lncConfig) {
_defineProperty(this, "_namespace", void 0);
_defineProperty(this, "credentials", void 0);
_defineProperty(this, "lnd", void 0);
_defineProperty(this, "_emitter", void 0);
_defineProperty(this, "_subscriptions", []);
_defineProperty(this, "onLocalPrivCreate", keyHex => {
_log.log.debug('local private key created: ' + keyHex);
this.credentials.localKey = keyHex;
@@ -46,6 +54,7 @@ class LNC {
if (config.pairingPhrase) this.credentials.pairingPhrase = config.pairingPhrase;
}
this.lnd = new _lncCore.LndApi(_createRpc.createRpc, this);
this._emitter = new _reactNative.NativeEventEmitter(_reactNative.NativeModules.LncModule);
_reactNative.NativeModules.LncModule.initLNC(this._namespace);
}
async isConnected() {
@@ -73,9 +82,22 @@ class LNC {
// do not attempt to connect multiple times
const connected = await this.isConnected();
if (connected) return;
_reactNative.NativeModules.LncModule.registerLocalPrivCreateCallback(this._namespace, this.onLocalPrivCreate);
_reactNative.NativeModules.LncModule.registerRemoteKeyReceiveCallback(this._namespace, this.onRemoteKeyReceive);
_reactNative.NativeModules.LncModule.registerAuthDataCallback(this._namespace, this.onAuthData);
// Under React Native's new architecture, RCTResponseSenderBlock /
// com.facebook.react.bridge.Callback may only be invoked once. The Go
// LNC bridge fires these callbacks repeatedly over the session
// lifetime, so we route them through RCTEventEmitter instead.
this._removeSubscriptions();
this._subscriptions = [this._emitter.addListener(EVENT_LOCAL_PRIV_CREATE, ({
result
}) => this.onLocalPrivCreate(result)), this._emitter.addListener(EVENT_REMOTE_KEY_RECEIVE, ({
result
}) => this.onRemoteKeyReceive(result)), this._emitter.addListener(EVENT_AUTH_DATA, ({
result
}) => this.onAuthData(result))];
_reactNative.NativeModules.LncModule.registerLocalPrivCreateCallback(this._namespace, EVENT_LOCAL_PRIV_CREATE);
_reactNative.NativeModules.LncModule.registerRemoteKeyReceiveCallback(this._namespace, EVENT_REMOTE_KEY_RECEIVE);
_reactNative.NativeModules.LncModule.registerAuthDataCallback(this._namespace, EVENT_AUTH_DATA);
const {
pairingPhrase,
localKey,
@@ -92,8 +114,15 @@ class LNC {
* Disconnects from the proxy server
*/
disconnect() {
this._removeSubscriptions();
_reactNative.NativeModules.LncModule.disconnect(this._namespace);
}
_removeSubscriptions() {
for (const sub of this._subscriptions) {
sub.remove();
}
this._subscriptions = [];
}
/**
* Emulates a GRPC request but uses the mobile client instead to communicate with the LND node
File diff suppressed because one or more lines are too long
+33 -4
View File
@@ -1,7 +1,7 @@
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
import { NativeModules } from 'react-native';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { LndApi, snakeKeysToCamel } from '@lightninglabs/lnc-core';
import { createRpc } from './api/createRpc';
import LncCredentialStore from './util/credentialStore';
@@ -12,11 +12,19 @@ const DEFAULT_CONFIG = {
namespace: 'default',
serverHost: 'mailbox.terminal.lightning.today:443'
};
// Native event names emitted by LncModule for the persistent register callbacks.
// Kept in sync with ios/LncMobile/LncModule.mm and android/.../LncModule.kt.
const EVENT_LOCAL_PRIV_CREATE = 'lnc.localPrivCreate';
const EVENT_REMOTE_KEY_RECEIVE = 'lnc.remoteKeyReceive';
const EVENT_AUTH_DATA = 'lnc.authData';
export default class LNC {
constructor(lncConfig) {
_defineProperty(this, "_namespace", void 0);
_defineProperty(this, "credentials", void 0);
_defineProperty(this, "lnd", void 0);
_defineProperty(this, "_emitter", void 0);
_defineProperty(this, "_subscriptions", []);
_defineProperty(this, "onLocalPrivCreate", keyHex => {
log.debug('local private key created: ' + keyHex);
this.credentials.localKey = keyHex;
@@ -40,6 +48,7 @@ export default class LNC {
if (config.pairingPhrase) this.credentials.pairingPhrase = config.pairingPhrase;
}
this.lnd = new LndApi(createRpc, this);
this._emitter = new NativeEventEmitter(NativeModules.LncModule);
NativeModules.LncModule.initLNC(this._namespace);
}
async isConnected() {
@@ -67,9 +76,22 @@ export default class LNC {
// do not attempt to connect multiple times
const connected = await this.isConnected();
if (connected) return;
NativeModules.LncModule.registerLocalPrivCreateCallback(this._namespace, this.onLocalPrivCreate);
NativeModules.LncModule.registerRemoteKeyReceiveCallback(this._namespace, this.onRemoteKeyReceive);
NativeModules.LncModule.registerAuthDataCallback(this._namespace, this.onAuthData);
// Under React Native's new architecture, RCTResponseSenderBlock /
// com.facebook.react.bridge.Callback may only be invoked once. The Go
// LNC bridge fires these callbacks repeatedly over the session
// lifetime, so we route them through RCTEventEmitter instead.
this._removeSubscriptions();
this._subscriptions = [this._emitter.addListener(EVENT_LOCAL_PRIV_CREATE, ({
result
}) => this.onLocalPrivCreate(result)), this._emitter.addListener(EVENT_REMOTE_KEY_RECEIVE, ({
result
}) => this.onRemoteKeyReceive(result)), this._emitter.addListener(EVENT_AUTH_DATA, ({
result
}) => this.onAuthData(result))];
NativeModules.LncModule.registerLocalPrivCreateCallback(this._namespace, EVENT_LOCAL_PRIV_CREATE);
NativeModules.LncModule.registerRemoteKeyReceiveCallback(this._namespace, EVENT_REMOTE_KEY_RECEIVE);
NativeModules.LncModule.registerAuthDataCallback(this._namespace, EVENT_AUTH_DATA);
const {
pairingPhrase,
localKey,
@@ -86,8 +108,15 @@ export default class LNC {
* Disconnects from the proxy server
*/
disconnect() {
this._removeSubscriptions();
NativeModules.LncModule.disconnect(this._namespace);
}
_removeSubscriptions() {
for (const sub of this._subscriptions) {
sub.remove();
}
this._subscriptions = [];
}
/**
* Emulates a GRPC request but uses the mobile client instead to communicate with the LND node
File diff suppressed because one or more lines are too long
@@ -4,6 +4,8 @@ export default class LNC {
_namespace: string;
credentials: CredentialStore;
lnd: LndApi;
private _emitter;
private _subscriptions;
constructor(lncConfig?: LncConfig);
onLocalPrivCreate: (keyHex: string) => void;
onRemoteKeyReceive: (keyHex: string) => void;
@@ -22,6 +24,7 @@ export default class LNC {
* Disconnects from the proxy server
*/
disconnect(): void;
private _removeSubscriptions;
/**
* Emulates a GRPC request but uses the mobile client instead to communicate with the LND node
* @param method the GRPC method to call on the service
@@ -1 +1 @@
{"version":3,"file":"lnc.d.ts","sourceRoot":"","sources":["../../lib/lnc.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAoB,MAAM,yBAAyB,CAAC;AAEnE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAUzD,MAAM,CAAC,OAAO,OAAO,GAAG;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,eAAe,CAAC;IAE7B,GAAG,EAAE,MAAM,CAAC;gBAEA,SAAS,CAAC,EAAE,SAAS;IAqBjC,iBAAiB,WAAY,MAAM,UAGjC;IAEF,kBAAkB,WAAY,MAAM,UAGlC;IAEF,UAAU,WAAY,MAAM,UAE1B;IAEI,WAAW;IAIX,MAAM;IAIN,MAAM;IAKN,UAAU;IAIV,QAAQ,CAAC,UAAU,EAAE,MAAM;IAOjC;;;OAGG;IACG,OAAO;IAkCb;;OAEG;IACH,UAAU;IAIV;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwB9D;;;;;;;OAOG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM;CAMtD"}
{"version":3,"file":"lnc.d.ts","sourceRoot":"","sources":["../../lib/lnc.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,MAAM,EAAoB,MAAM,yBAAyB,CAAC;AAEnE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAgBzD,MAAM,CAAC,OAAO,OAAO,GAAG;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,eAAe,CAAC;IAE7B,GAAG,EAAE,MAAM,CAAC;IAEZ,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,cAAc,CAA6B;gBAEvC,SAAS,CAAC,EAAE,SAAS;IAsBjC,iBAAiB,WAAY,MAAM,UAGjC;IAEF,kBAAkB,WAAY,MAAM,UAGlC;IAEF,UAAU,WAAY,MAAM,UAE1B;IAEI,WAAW;IAIX,MAAM;IAIN,MAAM;IAKN,UAAU;IAIV,QAAQ,CAAC,UAAU,EAAE,MAAM;IAOjC;;;OAGG;IACG,OAAO;IAwDb;;OAEG;IACH,UAAU;IAKV,OAAO,CAAC,oBAAoB;IAO5B;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwB9D;;;;;;;OAOG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM;CAMtD"}
+48 -4
View File
@@ -1,4 +1,8 @@
import { NativeModules } from 'react-native';
import {
EmitterSubscription,
NativeEventEmitter,
NativeModules
} from 'react-native';
import { LndApi, snakeKeysToCamel } from '@lightninglabs/lnc-core';
import { createRpc } from './api/createRpc';
import { CredentialStore, LncConfig } from './types/lnc';
@@ -11,12 +15,21 @@ const DEFAULT_CONFIG = {
serverHost: 'mailbox.terminal.lightning.today:443'
} as Required<LncConfig>;
// Native event names emitted by LncModule for the persistent register callbacks.
// Kept in sync with ios/LncMobile/LncModule.mm and android/.../LncModule.kt.
const EVENT_LOCAL_PRIV_CREATE = 'lnc.localPrivCreate';
const EVENT_REMOTE_KEY_RECEIVE = 'lnc.remoteKeyReceive';
const EVENT_AUTH_DATA = 'lnc.authData';
export default class LNC {
_namespace: string;
credentials: CredentialStore;
lnd: LndApi;
private _emitter: NativeEventEmitter;
private _subscriptions: EmitterSubscription[] = [];
constructor(lncConfig?: LncConfig) {
// merge the passed in config with the defaults
const config = Object.assign({}, DEFAULT_CONFIG, lncConfig);
@@ -35,6 +48,7 @@ export default class LNC {
}
this.lnd = new LndApi(createRpc, this);
this._emitter = new NativeEventEmitter(NativeModules.LncModule);
NativeModules.LncModule.initLNC(this._namespace);
}
@@ -85,17 +99,39 @@ export default class LNC {
const connected = await this.isConnected();
if (connected) return;
// Under React Native's new architecture, RCTResponseSenderBlock /
// com.facebook.react.bridge.Callback may only be invoked once. The Go
// LNC bridge fires these callbacks repeatedly over the session
// lifetime, so we route them through RCTEventEmitter instead.
this._removeSubscriptions();
this._subscriptions = [
this._emitter.addListener(
EVENT_LOCAL_PRIV_CREATE,
({ result }: { result: string }) =>
this.onLocalPrivCreate(result)
),
this._emitter.addListener(
EVENT_REMOTE_KEY_RECEIVE,
({ result }: { result: string }) =>
this.onRemoteKeyReceive(result)
),
this._emitter.addListener(
EVENT_AUTH_DATA,
({ result }: { result: string }) => this.onAuthData(result)
)
];
NativeModules.LncModule.registerLocalPrivCreateCallback(
this._namespace,
this.onLocalPrivCreate
EVENT_LOCAL_PRIV_CREATE
);
NativeModules.LncModule.registerRemoteKeyReceiveCallback(
this._namespace,
this.onRemoteKeyReceive
EVENT_REMOTE_KEY_RECEIVE
);
NativeModules.LncModule.registerAuthDataCallback(
this._namespace,
this.onAuthData
EVENT_AUTH_DATA
);
const { pairingPhrase, localKey, remoteKey, serverHost } =
@@ -118,9 +154,17 @@ export default class LNC {
* Disconnects from the proxy server
*/
disconnect() {
this._removeSubscriptions();
NativeModules.LncModule.disconnect(this._namespace);
}
private _removeSubscriptions() {
for (const sub of this._subscriptions) {
sub.remove();
}
this._subscriptions = [];
}
/**
* Emulates a GRPC request but uses the mobile client instead to communicate with the LND node
* @param method the GRPC method to call on the service