Use xo-cash/utils sse. Add vending machine template. Greatly improve startup times.
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@xo-cash/state": "file:../state",
|
||||
"@xo-cash/templates": "file:../templates",
|
||||
"@xo-cash/types": "^0.0.1",
|
||||
"@xo-cash/utils": "file:../utils",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
@@ -47,16 +48,16 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.8",
|
||||
"@electrum-cash/application": "^0.2.3-development.13424909069",
|
||||
"@electrum-cash/application": "^0.2.3-development.13447192992",
|
||||
"@electrum-cash/network": "^4.2.2",
|
||||
"@electrum-cash/protocol": "^2.3.1",
|
||||
"@electrum-cash/servers": "^3.1.0",
|
||||
"@xo-cash/crypto": "^0.0.1",
|
||||
"@xo-cash/primitives": "0.0.1",
|
||||
"@xo-cash/state": "0.0.2",
|
||||
"@xo-cash/crypto": "0.0.1",
|
||||
"@xo-cash/primitives": "file:../primitives",
|
||||
"@xo-cash/state": "file:../state",
|
||||
"@xo-cash/templates": "0.0.1",
|
||||
"@xo-cash/types": "0.0.1",
|
||||
"@xo-cash/utils": "0.0.1",
|
||||
"@xo-cash/types": "^0.0.1-development.14519184304",
|
||||
"@xo-cash/utils": "^0.0.1-development.14519184505",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -140,6 +141,37 @@
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
},
|
||||
"../utils": {
|
||||
"name": "@xo-cash/utils",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.8",
|
||||
"@xo-cash/types": "0.0.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chalp/eslint-airbnb": "^1.3.0",
|
||||
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.7.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||
"@typescript-eslint/parser": "^8.53.1",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||
"@xo-cash/eslint-config": "1.0.1",
|
||||
"@xo-cash/templates": "0.0.1",
|
||||
"cspell": "^9.6.0",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.6.2",
|
||||
"tsdown": "^0.20.0-beta.4",
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-coverage": "^4.0.2",
|
||||
"typescript": "^5.3.2",
|
||||
"typescript-eslint": "^8.53.1",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
|
||||
@@ -977,6 +1009,10 @@
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xo-cash/utils": {
|
||||
"resolved": "../utils",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@xo-cash/state": "file:../state",
|
||||
"@xo-cash/templates": "file:../templates",
|
||||
"@xo-cash/types": "^0.0.1",
|
||||
"@xo-cash/utils": "file:../utils",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
|
||||
@@ -18,10 +18,13 @@ import { EventEmitter } from "../utils/event-emitter.js";
|
||||
|
||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||
import { createHash } from "crypto";
|
||||
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
import { parseTemplate } from "@xo-cash/engine";
|
||||
|
||||
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||
import { vendingMachineTemplate } from "../templates/vending-machine.js";
|
||||
import { wrapBCHTemplate } from "../templates/wrap-template.js";
|
||||
|
||||
export type AppEventMap = {
|
||||
"invitation-added": Invitation;
|
||||
"invitation-removed": Invitation;
|
||||
@@ -53,6 +56,12 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public settings: SettingsService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
/**
|
||||
* Incremented whenever the invitation list or any invitation's data/status changes.
|
||||
* Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations.
|
||||
*/
|
||||
public invitationsRevision = 0;
|
||||
private invitationRevisions = new Map<string, number>();
|
||||
private invitationEventCleanup = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -82,7 +91,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||
// Import the default P2PKH template
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
await engine.importTemplate(vendingMachineTemplate);
|
||||
await engine.importTemplate(wrapBCHTemplate);
|
||||
|
||||
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
||||
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
||||
const updateTemplates = async () => {
|
||||
@@ -160,8 +171,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
// Create the invitation
|
||||
const invitationInstance = await Invitation.create(invitation, deps);
|
||||
|
||||
// Add the invitation to the invitations array
|
||||
// Attach listeners before SSE connects so updates are not missed.
|
||||
await this.addInvitation(invitationInstance);
|
||||
await invitationInstance.start();
|
||||
|
||||
return invitationInstance;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
||||
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
XOInvitationInput,
|
||||
XOInvitationOutput,
|
||||
XOInvitationVariableValue,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type {
|
||||
AcceptInvitationParameters,
|
||||
AppendInvitationParameters,
|
||||
InvitationParameters,
|
||||
Engine,
|
||||
GetSpendableResourcesParameters,
|
||||
} from "@xo-cash/engine";
|
||||
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
@@ -15,10 +14,6 @@ import type {
|
||||
} from "@xo-cash/types";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import {
|
||||
bigIntToBinUint64LE,
|
||||
bigIntToBinUintBE,
|
||||
bigIntToBinUintLE,
|
||||
bigIntToVmNumber,
|
||||
binToHex,
|
||||
encodeTransaction,
|
||||
generateTransaction,
|
||||
@@ -90,13 +85,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
|
||||
// engine invitation (I have no idea if this is required)
|
||||
const engineInvitation = await dependencies.engine.acceptInvitation(invitation);
|
||||
const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation));
|
||||
|
||||
// Create the invitation
|
||||
const invitationInstance = new Invitation(engineInvitation, dependencies);
|
||||
|
||||
// Start the invitation and its tracking
|
||||
await invitationInstance.start();
|
||||
invitationInstance.start();
|
||||
|
||||
return invitationInstance;
|
||||
}
|
||||
@@ -387,7 +382,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Accept the invitation
|
||||
*/
|
||||
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
||||
async accept(acceptParams?: InvitationParameters): Promise<void> {
|
||||
// Accept the invitation
|
||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||
|
||||
@@ -438,7 +433,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Append a commit to the invitation
|
||||
*/
|
||||
async append(data: AppendInvitationParameters): Promise<void> {
|
||||
async append(data: InvitationParameters): Promise<void> {
|
||||
try {
|
||||
await this.engine.acceptInvitation(this.data);
|
||||
} catch (err) {
|
||||
// Literally do nothing here. We are just trying to accept the invitation in case we haven't already
|
||||
}
|
||||
|
||||
// Append the commit to the invitation
|
||||
this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OracleClient } from '@generalprotocols/oracle-client';
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
import {
|
||||
type RatesEventMap,
|
||||
@@ -73,8 +74,17 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
settings: SettingsService,
|
||||
adapter?: RatesAdapter,
|
||||
): Promise<RatesService> {
|
||||
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
|
||||
return new RatesService(resolvedAdapter, settings);
|
||||
if (adapter) {
|
||||
return new RatesService(adapter, settings);
|
||||
}
|
||||
|
||||
const oracleClient = new OracleClient();
|
||||
oracleClient.start();
|
||||
|
||||
const ratesOracle = new RatesOracle(oracleClient, settings);
|
||||
ratesOracle.start();
|
||||
|
||||
return new RatesService(ratesOracle, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
277
src/templates/vending-machine.ts
Normal file
277
src/templates/vending-machine.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Vending machine payment template.
|
||||
*
|
||||
* Merchant creates a purchaseItems invitation with receipt variables;
|
||||
* customer funds and signs the composable transaction.
|
||||
*/
|
||||
export const vendingMachineTemplate: XOTemplate = {
|
||||
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
|
||||
name: 'Vending Machine',
|
||||
description: 'Purchase items from a vending machine with an itemized receipt.',
|
||||
icon: 'wallet',
|
||||
version: '1',
|
||||
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'],
|
||||
|
||||
defaults: {
|
||||
change: {
|
||||
output: 'changeOutput',
|
||||
role: 'merchant',
|
||||
generate: ['merchantKey'],
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Merchant',
|
||||
description: 'The vending machine operator receiving payment.',
|
||||
icon: 'owner',
|
||||
},
|
||||
customer: {
|
||||
name: 'Customer',
|
||||
description: 'The customer paying for items.',
|
||||
icon: 'sender',
|
||||
},
|
||||
},
|
||||
|
||||
start: [
|
||||
{
|
||||
action: 'purchaseItems',
|
||||
role: 'merchant',
|
||||
generate: ['merchantKey'],
|
||||
},
|
||||
],
|
||||
|
||||
actions: {
|
||||
purchaseItems: {
|
||||
name: 'Purchase Items',
|
||||
description: 'Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats',
|
||||
icon: 'request',
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Sell Items',
|
||||
description: 'Receive payment for $(<receiptSummary>)',
|
||||
icon: 'request',
|
||||
requirements: {
|
||||
secrets: ['merchantKey'],
|
||||
variables: [
|
||||
'totalSatoshis',
|
||||
'orderId',
|
||||
'merchantName',
|
||||
'receiptSummary',
|
||||
'lineItemsJson',
|
||||
],
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
name: 'Pay',
|
||||
description: 'Pay $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
icon: 'send',
|
||||
requirements: {},
|
||||
},
|
||||
},
|
||||
|
||||
requirements: {
|
||||
participants: [
|
||||
{ role: 'merchant', slots: { min: 1, max: 1 } },
|
||||
{ role: 'customer', slots: { min: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
transaction: 'purchaseItemsTransaction',
|
||||
},
|
||||
},
|
||||
|
||||
transactions: {
|
||||
purchaseItemsTransaction: {
|
||||
name: 'Vending Purchase',
|
||||
description: 'Order $(<orderId>): $(<receiptSummary>)',
|
||||
icon: 'request',
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Received Payment',
|
||||
description: 'Received $(<totalSatoshis>) sats from $(<merchantName>) sale',
|
||||
icon: 'receive',
|
||||
},
|
||||
customer: {
|
||||
name: 'Sent Payment',
|
||||
description: 'Paid $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
icon: 'send',
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
outputs: [{ output: 'purchaseOutput' }],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
},
|
||||
|
||||
/** No custom input templates — customer UTXOs are selected at funding time. */
|
||||
inputs: {},
|
||||
|
||||
outputs: {
|
||||
changeOutput: {
|
||||
name: 'Change',
|
||||
description: 'Funds returned as change.',
|
||||
icon: 'receive',
|
||||
lockingScript: 'merchantReceivingLockingScript',
|
||||
},
|
||||
purchaseOutput: {
|
||||
name: 'Purchase Payment',
|
||||
description: '$(<totalSatoshis>) sats to $(<merchantName>)',
|
||||
icon: 'request',
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Payment Received',
|
||||
description: 'Received $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
},
|
||||
customer: {
|
||||
name: 'Payment Sent',
|
||||
description: 'Sent $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: 'merchantReceivingLockingScript',
|
||||
valueSatoshis: '$(<totalSatoshis>)',
|
||||
token: null,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScripts: {
|
||||
merchantReceivingLockingScript: {
|
||||
name: 'Merchant Receive',
|
||||
description: 'Funds received by the vending machine merchant.',
|
||||
icon: 'address',
|
||||
lockingType: 'p2pkh',
|
||||
lockingBytecode: 'lockMerchantP2PKH',
|
||||
unlockingBytecode: 'unlockMerchantP2PKH',
|
||||
actions: [],
|
||||
state: { variables: [], secrets: [] },
|
||||
balance: {},
|
||||
roles: {
|
||||
merchant: {
|
||||
state: {
|
||||
variables: [],
|
||||
secrets: ['merchantKey'],
|
||||
},
|
||||
actions: [],
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
scripts: {
|
||||
lockMerchantP2PKH:
|
||||
'OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG',
|
||||
unlockMerchantP2PKH:
|
||||
'<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>',
|
||||
},
|
||||
|
||||
constants: {
|
||||
dustLimit: {
|
||||
name: 'Dust Limit',
|
||||
description: 'Minimum satoshis for P2PKH outputs.',
|
||||
type: 'integer',
|
||||
value: 546,
|
||||
},
|
||||
},
|
||||
|
||||
variables: {
|
||||
merchantKey: {
|
||||
name: 'Merchant Private Key',
|
||||
description: 'Private key for the vending machine merchant wallet.',
|
||||
type: 'bytes',
|
||||
hint: 'private_key',
|
||||
},
|
||||
totalSatoshis: {
|
||||
name: 'Total Price',
|
||||
description: 'Total purchase price in satoshis',
|
||||
type: 'integer',
|
||||
hint: 'satoshis',
|
||||
},
|
||||
orderId: {
|
||||
name: 'Order ID',
|
||||
description: 'Unique order identifier',
|
||||
type: 'string',
|
||||
},
|
||||
merchantName: {
|
||||
name: 'Merchant Name',
|
||||
description: 'Display name of the vending machine',
|
||||
type: 'string',
|
||||
},
|
||||
receiptSummary: {
|
||||
name: 'Receipt Summary',
|
||||
description: 'Human-readable list of purchased items',
|
||||
type: 'string',
|
||||
},
|
||||
lineItemsJson: {
|
||||
name: 'Line Items',
|
||||
description: 'JSON-encoded line items for the purchase',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
|
||||
icons: [
|
||||
{ name: 'wallet', hash: '0000000000000000000000' },
|
||||
{ name: 'owner', hash: '0000000000000000000000' },
|
||||
{ name: 'sender', hash: '0000000000000000000000' },
|
||||
{ name: 'request', hash: '0000000000000000000000' },
|
||||
{ name: 'receive', hash: '0000000000000000000000' },
|
||||
{ name: 'send', hash: '0000000000000000000000' },
|
||||
],
|
||||
|
||||
scenarios: [
|
||||
{
|
||||
name: 'purchase items happy path',
|
||||
description: 'Merchant requests payment for vending machine items.',
|
||||
action: 'purchaseItems',
|
||||
roles: [
|
||||
{
|
||||
role: 'merchant',
|
||||
values: {
|
||||
generated: {
|
||||
merchantKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8',
|
||||
},
|
||||
variables: {
|
||||
totalSatoshis: 3500,
|
||||
orderId: 'order-demo-1',
|
||||
merchantName: 'XO Snack Machine',
|
||||
receiptSummary: '2× Cola, 1× Chips',
|
||||
lineItemsJson: '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
|
||||
},
|
||||
secrets: {},
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac',
|
||||
valueSatoshis: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'customer',
|
||||
values: {
|
||||
generated: {},
|
||||
variables: {},
|
||||
secrets: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export function AppProvider({
|
||||
});
|
||||
|
||||
// Start the AppService (loads existing invitations)
|
||||
await service.start();
|
||||
service.start();
|
||||
|
||||
// Set the service and mark as initialized
|
||||
setAppService(service);
|
||||
|
||||
@@ -158,9 +158,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
setSeedPhrase('');
|
||||
setSaveMnemonicChecked(false);
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('wallet');
|
||||
}, 500);
|
||||
navigate('wallet');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
||||
|
||||
@@ -45,7 +45,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
private targetDenominatorUnitCode: string = 'BCH';
|
||||
private unsubscribeFromSettings: OffCallback | null = null;
|
||||
|
||||
private constructor(client: OracleClient, settings: SettingsService) {
|
||||
public constructor(client: OracleClient, settings: SettingsService) {
|
||||
super();
|
||||
|
||||
this.client = client;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
import { EventEmitter } from "./event-emitter.js";
|
||||
import { SSESession, type SSEvent } from "./sse-client.js";
|
||||
// import { SSESession, type SSEvent } from "./sse-client.js";
|
||||
import { SSESession, type SSEvent } from "@xo-cash/utils";
|
||||
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||
|
||||
export type SyncServerEventMap = {
|
||||
@@ -38,7 +39,6 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
},
|
||||
|
||||
// Create our event bubblers
|
||||
onMessage: (event: SSEvent) => this.emit("message", event),
|
||||
onError: (error: unknown) =>
|
||||
this.emit(
|
||||
"error",
|
||||
@@ -48,6 +48,8 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
onConnected: () => this.emit("connected", undefined),
|
||||
},
|
||||
);
|
||||
|
||||
this.sse.on("message", (event: SSEvent) => this.emit("message", event));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +65,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
// Disconnect from the SSE Session
|
||||
this.sse.close();
|
||||
await this.sse.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user