XO Integration Part II
This commit is contained in:
Generated
+4
-4
@@ -8185,9 +8185,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb": {
|
"node_modules/idb": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||||
"integrity": "sha512-EkBCzUZSdhJV8PxMSbeEV//xguVKZu9hZZulM+2gHXI0t2hGVU3eYE6/XnH77DS6FM2FY8wl17aDcu9vXpvLWQ==",
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
@@ -14750,7 +14750,7 @@
|
|||||||
"@electrum-cash/protocol": "2.1.0-development.8075178089",
|
"@electrum-cash/protocol": "2.1.0-development.8075178089",
|
||||||
"@xocash/primitives": "*",
|
"@xocash/primitives": "*",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"idb": "^8.0.0",
|
"idb": "8.0.3",
|
||||||
"msgpackr": "^1.11.2",
|
"msgpackr": "^1.11.2",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ export class BlockHeader {
|
|||||||
return new this(bytes);
|
return new this(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static generateRandom(timestampUnix?: number): BlockHeader {
|
||||||
|
// Create buffer for the 80-byte header
|
||||||
|
const header = new Uint8Array(80);
|
||||||
|
|
||||||
|
// Fill with random bytes
|
||||||
|
crypto.getRandomValues(header);
|
||||||
|
|
||||||
|
// Optional: Set specific version if needed
|
||||||
|
// First 4 bytes is version (little-endian)
|
||||||
|
const version = 1;
|
||||||
|
header[0] = version & 0xff;
|
||||||
|
header[1] = (version >> 8) & 0xff;
|
||||||
|
header[2] = (version >> 16) & 0xff;
|
||||||
|
header[3] = (version >> 24) & 0xff;
|
||||||
|
|
||||||
|
// Optional: Set realistic timestamp (current time)
|
||||||
|
const timestamp = timestampUnix || Math.floor(Date.now() / 1000);
|
||||||
|
header[68] = timestamp & 0xff;
|
||||||
|
header[69] = (timestamp >> 8) & 0xff;
|
||||||
|
header[70] = (timestamp >> 16) & 0xff;
|
||||||
|
header[71] = (timestamp >> 24) & 0xff;
|
||||||
|
|
||||||
|
return this.fromBytes(header);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether the given BlochHeader is of a valid structure.
|
* Checks whether the given BlochHeader is of a valid structure.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"@electrum-cash/protocol": "2.1.0-development.8075178089",
|
"@electrum-cash/protocol": "2.1.0-development.8075178089",
|
||||||
"@xocash/primitives": "*",
|
"@xocash/primitives": "*",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"idb": "^8.0.0",
|
"idb": "8.0.3",
|
||||||
"msgpackr": "^1.11.2",
|
"msgpackr": "^1.11.2",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type BlockchainUnsubscribeCallback,
|
type BlockchainUnsubscribeCallback,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
import { Transaction } from '@xocash/primitives';
|
import { BlockHeader, Transaction, TransactionHash } from '@xocash/primitives';
|
||||||
|
|
||||||
export type BlockchainEvents = {
|
export type BlockchainEvents = {
|
||||||
isConnectedUpdated: boolean;
|
isConnectedUpdated: boolean;
|
||||||
@@ -80,6 +80,15 @@ export abstract class BaseBlockchain<
|
|||||||
address: string,
|
address: string,
|
||||||
): Promise<BlockchainTransactions>;
|
): Promise<BlockchainTransactions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the Block Header at the given height.
|
||||||
|
*
|
||||||
|
* @param {number} blockHeight - The block height of the header to retrieve
|
||||||
|
* @returns {Promise<BlockHeader>} A promise that resolves with the Block Header
|
||||||
|
* @throws {Error} If the block number is invalid or the query fails
|
||||||
|
*/
|
||||||
|
abstract fetchBlockHeader(blockHeight: number): Promise<BlockHeader>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves detailed information about a specific transaction.
|
* Retrieves detailed information about a specific transaction.
|
||||||
*
|
*
|
||||||
@@ -196,15 +205,29 @@ export abstract class BaseBlockchain<
|
|||||||
tx = Transaction.from(tx);
|
tx = Transaction.from(tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const txHash = tx.toHashHex();
|
// Then wait for either resolution or timeout
|
||||||
|
return this.waitForTransactionHash(tx.toHash(), timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTransactionHash(
|
||||||
|
txHash: TransactionHash | Uint8Array | string,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
) {
|
||||||
|
// Cast the transaction hash.
|
||||||
|
if (!(txHash instanceof TransactionHash)) {
|
||||||
|
txHash = TransactionHash.from(txHash);
|
||||||
|
}
|
||||||
|
|
||||||
// First, subscribe to the transaction
|
// First, subscribe to the transaction
|
||||||
let unsubscribeCallback: () => void;
|
let unsubscribeCallback: () => void;
|
||||||
const subscriptionPromise = this.subscribeTransaction(txHash, () => {
|
const subscriptionPromise = this.subscribeTransaction(
|
||||||
if (unsubscribeCallback) {
|
txHash.toHex(),
|
||||||
unsubscribeCallback();
|
() => {
|
||||||
}
|
if (unsubscribeCallback) {
|
||||||
});
|
unsubscribeCallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Then create the wait promise without an async executor
|
// Then create the wait promise without an async executor
|
||||||
const waitForTxPromise = new Promise<void>((resolve, reject) => {
|
const waitForTxPromise = new Promise<void>((resolve, reject) => {
|
||||||
@@ -218,7 +241,7 @@ export abstract class BaseBlockchain<
|
|||||||
// Create a subscribe function so that we get notified when transaction is seen by network
|
// Create a subscribe function so that we get notified when transaction is seen by network
|
||||||
unsubscribeCallback = () => {
|
unsubscribeCallback = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
this.unsubscribeTransaction(txHash, unsubscribeCallback);
|
this.unsubscribeTransaction(txHash.toHex(), unsubscribeCallback);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { type BaseStore, StoreInMemory } from '../storage/index.js';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Address,
|
Address,
|
||||||
|
BlockHeader,
|
||||||
ExtMap,
|
ExtMap,
|
||||||
Outpoint,
|
Outpoint,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
type TransactionSubscribeNotification,
|
type TransactionSubscribeNotification,
|
||||||
initializeElectrumClient,
|
initializeElectrumClient,
|
||||||
broadcastTransaction,
|
broadcastTransaction,
|
||||||
|
fetchBlockHeaderFromBlockHeight,
|
||||||
fetchUnspentTransactionOutputs,
|
fetchUnspentTransactionOutputs,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
fetchTransaction,
|
fetchTransaction,
|
||||||
@@ -278,7 +280,7 @@ export class BlockchainElectrum extends BaseBlockchain<
|
|||||||
return Transaction.fromHex(txHex).toRaw();
|
return Transaction.fromHex(txHex).toRaw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Decode the hex into our transaction primitive.
|
// Decode the raw data into our transaction primitive.
|
||||||
const transaction = Transaction.fromRaw(transactionRaw);
|
const transaction = Transaction.fromRaw(transactionRaw);
|
||||||
|
|
||||||
// Return the transaction hex.
|
// Return the transaction hex.
|
||||||
@@ -318,6 +320,32 @@ export class BlockchainElectrum extends BaseBlockchain<
|
|||||||
return transactionsIndexed;
|
return transactionsIndexed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchBlockHeader(blockHeight: number) {
|
||||||
|
// Wait for connection to be established before attempting to subscribe.
|
||||||
|
// TODO: Consider doing this at a higher level.
|
||||||
|
if (!this.isConnected) {
|
||||||
|
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the cache key.
|
||||||
|
const cacheKey = `blockHeader.${blockHeight}`;
|
||||||
|
|
||||||
|
// Attempt to get the transaction history from the cache or fetch them if not available.
|
||||||
|
const blockHeaderRaw = await this.store.getOrSet(cacheKey, async () => {
|
||||||
|
const headerHex = await fetchBlockHeaderFromBlockHeight(
|
||||||
|
this.electrumClient,
|
||||||
|
blockHeight,
|
||||||
|
);
|
||||||
|
return BlockHeader.fromHex(headerHex).toRaw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decode the raw data into our Block Header primitive.
|
||||||
|
const blockHeader = BlockHeader.fromRaw(blockHeaderRaw);
|
||||||
|
|
||||||
|
// Return the block header.
|
||||||
|
return blockHeader;
|
||||||
|
}
|
||||||
|
|
||||||
async broadcastTransaction(
|
async broadcastTransaction(
|
||||||
tx: Transaction | Uint8Array | string,
|
tx: Transaction | Uint8Array | string,
|
||||||
opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true },
|
opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Address,
|
Address,
|
||||||
|
BlockHeader,
|
||||||
ExtMap,
|
ExtMap,
|
||||||
Outpoint,
|
Outpoint,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -30,6 +31,7 @@ export class BlockchainInMemory extends BaseBlockchain {
|
|||||||
return blockchain as InstanceType<T>;
|
return blockchain as InstanceType<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private blockHeaders: Array<BlockHeader>;
|
||||||
private transactions: BlockchainTransactions;
|
private transactions: BlockchainTransactions;
|
||||||
private addressSubscriptions: Map<string, Set<BlockchainCallback>>;
|
private addressSubscriptions: Map<string, Set<BlockchainCallback>>;
|
||||||
private transactionSubscriptions: Map<string, Set<BlockchainCallback>>;
|
private transactionSubscriptions: Map<string, Set<BlockchainCallback>>;
|
||||||
@@ -40,6 +42,7 @@ export class BlockchainInMemory extends BaseBlockchain {
|
|||||||
this.transactions = new ExtMap<BlockchainTransaction>();
|
this.transactions = new ExtMap<BlockchainTransaction>();
|
||||||
this.addressSubscriptions = new Map();
|
this.addressSubscriptions = new Map();
|
||||||
this.transactionSubscriptions = new Map();
|
this.transactionSubscriptions = new Map();
|
||||||
|
this.blockHeaders = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {}
|
async start() {}
|
||||||
@@ -89,6 +92,17 @@ export class BlockchainInMemory extends BaseBlockchain {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchBlockHeader(blockHeight: number) {
|
||||||
|
const blockHeader = this.blockHeaders[blockHeight];
|
||||||
|
|
||||||
|
if (!blockHeader) {
|
||||||
|
throw new Error(`No block available for height ${blockHeight}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the block header.
|
||||||
|
return blockHeader;
|
||||||
|
}
|
||||||
|
|
||||||
async broadcastTransaction(
|
async broadcastTransaction(
|
||||||
tx: Transaction | Uint8Array | string,
|
tx: Transaction | Uint8Array | string,
|
||||||
opts: { skipValidation: boolean } = { skipValidation: false },
|
opts: { skipValidation: boolean } = { skipValidation: false },
|
||||||
@@ -326,6 +340,14 @@ export class BlockchainInMemory extends BaseBlockchain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mineBlock(outputs: Array<Output>): Promise<void> {
|
async mineBlock(outputs: Array<Output>): Promise<void> {
|
||||||
|
// Create a block header from the current timestamp.
|
||||||
|
const newBlockHeader = BlockHeader.generateRandom(
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add it to our list of block headers.
|
||||||
|
this.blockHeaders.push(newBlockHeader);
|
||||||
|
|
||||||
// Create a transaction that pays to our subscribed address.
|
// Create a transaction that pays to our subscribed address.
|
||||||
const transaction = Transaction.fromRaw({
|
const transaction = Transaction.fromRaw({
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ export interface Activity {
|
|||||||
// Define the snapshot structure
|
// Define the snapshot structure
|
||||||
export type SnapshotByType = {
|
export type SnapshotByType = {
|
||||||
[type: string]: {
|
[type: string]: {
|
||||||
[id: string]: Activity;
|
[entityId: string]: Activity;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActivitiesIndexedByKey<T extends Activity = Activity> = {
|
||||||
|
[entityId: string]: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivitiesIndexedByEntityId<T extends Activity = Activity> = {
|
||||||
|
[key: string]: T;
|
||||||
|
};
|
||||||
|
|
||||||
// Policy type definition
|
// Policy type definition
|
||||||
export type PolicyFunction = (
|
export type PolicyFunction = (
|
||||||
existingActivity: Activity | undefined,
|
existingActivity: Activity | undefined,
|
||||||
@@ -36,6 +44,7 @@ export type PolicyFunction = (
|
|||||||
) => Activity;
|
) => Activity;
|
||||||
|
|
||||||
export type ActivitiesEvents = {
|
export type ActivitiesEvents = {
|
||||||
|
snapshotUpdated: undefined;
|
||||||
[key: `updated:${string}`]: { activity: Activity };
|
[key: `updated:${string}`]: { activity: Activity };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,12 +181,17 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
|
|||||||
/**
|
/**
|
||||||
* Get all activities of a specific type
|
* Get all activities of a specific type
|
||||||
*/
|
*/
|
||||||
public getByType<T extends Activity>(type: T['key']): T[] {
|
public getByKey<T extends Activity>(
|
||||||
const activities = this.snapshot[type];
|
key: T['key'],
|
||||||
|
): ActivitiesIndexedByKey<T> {
|
||||||
|
const activities = this.snapshot[key];
|
||||||
if (!activities) {
|
if (!activities) {
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
return Object.values(activities) as T[];
|
|
||||||
|
// NOTE: We want to return a new object, not a reference to our current object.
|
||||||
|
// But we only need to "clone" the first level as each activity itself is immutable.
|
||||||
|
return { ...activities } as ActivitiesIndexedByKey<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,6 +241,9 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
|
|||||||
|
|
||||||
// Emit the updated activity.
|
// Emit the updated activity.
|
||||||
this.emit(`updated:${activity.key}`, { activity });
|
this.emit(`updated:${activity.key}`, { activity });
|
||||||
|
|
||||||
|
// Emit an event indicating our snapshot has been updated.
|
||||||
|
this.emit('snapshotUpdated', undefined);
|
||||||
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
|
} else if (storeKey !== this.storeKeyMap.get(snapshotKey)) {
|
||||||
// If we kept the existing activity, delete the new activity from the store
|
// If we kept the existing activity, delete the new activity from the store
|
||||||
// await this.store.set(storeKey, null);
|
// await this.store.set(storeKey, null);
|
||||||
@@ -261,6 +278,8 @@ export class Activities extends EventEmitter<ActivitiesEvents> {
|
|||||||
await this.processActivity(storeKey, activity);
|
await this.processActivity(storeKey, activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.emit('snapshotUpdated', undefined);
|
||||||
|
|
||||||
return this.snapshot;
|
return this.snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type StorageOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StoreEvents = {
|
export type StoreEvents = {
|
||||||
|
[key: `set:${string}`]: any;
|
||||||
set: { key: string; value: any };
|
set: { key: string; value: any };
|
||||||
delete: { key: string };
|
delete: { key: string };
|
||||||
clear: undefined;
|
clear: undefined;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export class StorageLocalStorage extends BaseStorage {
|
|||||||
const stores = new Set<string>();
|
const stores = new Set<string>();
|
||||||
|
|
||||||
// Iterate through localStorage to find all stores with our dbName prefix
|
// Iterate through localStorage to find all stores with our dbName prefix
|
||||||
|
// NOTE: I think this was me (Jim) just being stupid.
|
||||||
|
// We don't want to do this - we want to over-ride "on" to emit on first subscription.
|
||||||
|
// And then call super.on?
|
||||||
|
/*
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (key && key.startsWith(prefix)) {
|
if (key && key.startsWith(prefix)) {
|
||||||
@@ -35,6 +39,7 @@ export class StorageLocalStorage extends BaseStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
return Array.from(stores);
|
return Array.from(stores);
|
||||||
}
|
}
|
||||||
@@ -171,7 +176,15 @@ export class StoreLocalStorage extends BaseStore {
|
|||||||
encryption?: Encrypter,
|
encryption?: Encrypter,
|
||||||
parentStorage?: StorageLocalStorage,
|
parentStorage?: StorageLocalStorage,
|
||||||
) {
|
) {
|
||||||
return new this(dbName, storeName, encryption, parentStorage);
|
const store = new this(dbName, storeName, encryption, parentStorage);
|
||||||
|
|
||||||
|
// Emit events for all entries.
|
||||||
|
const allEntries = await store.all();
|
||||||
|
Object.entries(allEntries).forEach(([key, value]) =>
|
||||||
|
store.emit(`set:${key}`, value),
|
||||||
|
);
|
||||||
|
|
||||||
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
async all<T>() {
|
async all<T>() {
|
||||||
@@ -235,6 +248,7 @@ export class StoreLocalStorage extends BaseStore {
|
|||||||
// LocalStorage can only store strings, so we convert the binary data
|
// LocalStorage can only store strings, so we convert the binary data
|
||||||
localStorage.setItem(fullKey, encryptedValue);
|
localStorage.setItem(fullKey, encryptedValue);
|
||||||
this.emit('set', { key, value });
|
this.emit('set', { key, value });
|
||||||
|
this.emit(`set:${key}`, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string) {
|
async delete(key: string) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface WalletDescription extends Activity {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WalletCurrency extends Activity {
|
||||||
|
key: 'WalletCurrency';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WalletHDAddressGap extends Activity {
|
export interface WalletHDAddressGap extends Activity {
|
||||||
key: 'WalletHDAddressGap';
|
key: 'WalletHDAddressGap';
|
||||||
value: number;
|
value: number;
|
||||||
|
|||||||
@@ -173,5 +173,5 @@ export abstract class BaseWallet<
|
|||||||
*/
|
*/
|
||||||
abstract sendTransaction(
|
abstract sendTransaction(
|
||||||
template: Partial<TransactionTemplate>,
|
template: Partial<TransactionTemplate>,
|
||||||
): Promise<void>;
|
): Promise<Uint8Array>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,9 @@ export class WalletHD extends BaseWallet {
|
|||||||
|
|
||||||
// Broadcast the transaction to the blockchain.
|
// Broadcast the transaction to the blockchain.
|
||||||
await this.blockchain.broadcastTransaction(generatedTxBytes);
|
await this.blockchain.broadcastTransaction(generatedTxBytes);
|
||||||
|
|
||||||
|
// Return the transaction.
|
||||||
|
return generatedTxBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deriveWallets(
|
async deriveWallets(
|
||||||
|
|||||||
@@ -414,6 +414,9 @@ export class WalletP2PKH extends BaseWallet {
|
|||||||
|
|
||||||
// Broadcast the transaction to the blockchain.
|
// Broadcast the transaction to the blockchain.
|
||||||
await this.blockchain.broadcastTransaction(generatedTxBytes);
|
await this.blockchain.broadcastTransaction(generatedTxBytes);
|
||||||
|
|
||||||
|
// Return the transaction.
|
||||||
|
return generatedTxBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onAddressNotification(
|
private async onAddressNotification(
|
||||||
|
|||||||
Reference in New Issue
Block a user