mirror of
https://github.com/farcasterxyz/hub-monorepo.git
synced 2026-01-10 13:48:06 -05:00
feat: engine tracks signers for usernames (#11)
This commit is contained in:
@@ -13,14 +13,18 @@ class Client {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
return this.wallet.address;
|
||||
}
|
||||
|
||||
generateRoot(ethBlockNum: number, ethblockHash: string, prevRootBlockHash?: string): SignedMessage<RootMessageBody> {
|
||||
const item = {
|
||||
message: {
|
||||
body: {
|
||||
blockHash: ethblockHash,
|
||||
chainType: 'cast' as const,
|
||||
prevRootBlockHash: prevRootBlockHash || '0x0', // TODO: change
|
||||
prevRootLastHash: '0x0', // TODO: change, how are null props serialized.s
|
||||
prevRootBlockHash: prevRootBlockHash || '0x0',
|
||||
prevRootLastHash: '0x0',
|
||||
schema: 'farcaster.xyz/schemas/v1/root' as const,
|
||||
},
|
||||
index: 0,
|
||||
|
||||
@@ -47,7 +47,7 @@ const Debugger = {
|
||||
// Determine the chain which has the user's latest message, so that we can mark it green.
|
||||
let latestSignedAt = 0;
|
||||
nodes.forEach((node) => {
|
||||
const chainLatestSignedAt = latestChainSignedAt(node.engine.getCastChains(username));
|
||||
const chainLatestSignedAt = latestChainSignedAt(node.engine.getChains(username));
|
||||
if (chainLatestSignedAt > latestSignedAt) {
|
||||
latestSignedAt = chainLatestSignedAt;
|
||||
}
|
||||
@@ -55,9 +55,9 @@ const Debugger = {
|
||||
|
||||
// For each user's chain in each node, print the chain.
|
||||
for (const node of nodes.values()) {
|
||||
const chainLatestSignedAt = latestChainSignedAt(node.engine.getCastChains(username));
|
||||
const chainLatestSignedAt = latestChainSignedAt(node.engine.getChains(username));
|
||||
const color = chainLatestSignedAt === latestSignedAt ? colors.green : colors.red;
|
||||
table.push({ [node.name]: visualizeChains(node.engine.getCastChains(username), color) });
|
||||
table.push({ [node.name]: visualizeChains(node.engine.getChains(username), color) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Engine from '~/engine';
|
||||
import Engine, { Signer } from '~/engine';
|
||||
import { Factories } from '~/factories';
|
||||
import { Root } from '~/types';
|
||||
import Faker from 'faker';
|
||||
|
||||
const engine = new Engine();
|
||||
const username = 'alice';
|
||||
@@ -11,48 +12,87 @@ describe('addRoot', () => {
|
||||
let rootA130: Root;
|
||||
let rootA130B: Root;
|
||||
let rootB140: Root;
|
||||
let rootC90: Root;
|
||||
|
||||
let alicePrivateKey: string;
|
||||
let aliceAddress: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
rootA110 = await Factories.Root.create({ message: { rootBlock: 110, username: 'alice' } });
|
||||
const keypair = await Factories.EthAddress.create({});
|
||||
alicePrivateKey = keypair.privateKey;
|
||||
aliceAddress = keypair.address;
|
||||
|
||||
rootA120 = await Factories.Root.create({
|
||||
message: {
|
||||
rootBlock: 120,
|
||||
username: 'alice',
|
||||
body: { prevRootBlockHash: rootA110.message.body.blockHash },
|
||||
},
|
||||
});
|
||||
rootC90 = await Factories.Root.create(
|
||||
{ message: { rootBlock: 90, username: 'alice' } },
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
|
||||
rootA130 = await Factories.Root.create({
|
||||
message: {
|
||||
rootBlock: 130,
|
||||
username: 'alice',
|
||||
body: { prevRootBlockHash: rootA120.message.body.blockHash },
|
||||
},
|
||||
});
|
||||
rootA110 = await Factories.Root.create(
|
||||
{ message: { rootBlock: 110, username: 'alice' } },
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
|
||||
rootA130B = await Factories.Root.create({
|
||||
message: {
|
||||
rootBlock: 130,
|
||||
username: 'alice',
|
||||
body: rootA130.message.body,
|
||||
signedAt: rootA130.message.signedAt + 1,
|
||||
rootA120 = await Factories.Root.create(
|
||||
{
|
||||
message: {
|
||||
rootBlock: 120,
|
||||
username: 'alice',
|
||||
body: { prevRootBlockHash: rootA110.message.body.blockHash },
|
||||
},
|
||||
signer: keypair.address,
|
||||
},
|
||||
});
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
|
||||
rootB140 = await Factories.Root.create({
|
||||
message: {
|
||||
rootBlock: 140,
|
||||
username: 'alice',
|
||||
rootA130 = await Factories.Root.create(
|
||||
{
|
||||
message: {
|
||||
rootBlock: 130,
|
||||
username: 'alice',
|
||||
body: { prevRootBlockHash: rootA120.message.body.blockHash },
|
||||
},
|
||||
},
|
||||
});
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
|
||||
rootA130B = await Factories.Root.create(
|
||||
{
|
||||
message: {
|
||||
rootBlock: 130,
|
||||
username: 'alice',
|
||||
body: rootA130.message.body,
|
||||
signedAt: rootA130.message.signedAt + 1,
|
||||
},
|
||||
},
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
|
||||
rootB140 = await Factories.Root.create(
|
||||
{
|
||||
message: {
|
||||
rootBlock: 140,
|
||||
username: 'alice',
|
||||
},
|
||||
},
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
engine.reset();
|
||||
engine.resetChains();
|
||||
engine.resetUsers();
|
||||
|
||||
const aliceRegistrationSignerChange = {
|
||||
blockNumber: 99,
|
||||
blockHash: Faker.datatype.hexaDecimal(64).toLowerCase(),
|
||||
logIndex: 0,
|
||||
address: aliceAddress,
|
||||
};
|
||||
|
||||
engine.addSignerChange('alice', aliceRegistrationSignerChange);
|
||||
});
|
||||
|
||||
const subject = () => engine.getCastChains(username);
|
||||
const subject = () => engine.getChains(username);
|
||||
|
||||
describe('fails with invalid inputs', () => {
|
||||
test('of string', async () => {
|
||||
@@ -72,8 +112,47 @@ describe('addRoot', () => {
|
||||
// TODO: test with Reactions, Follows
|
||||
});
|
||||
|
||||
describe('fails with invalid signers', () => {
|
||||
test('fails if the signer is unknown', async () => {
|
||||
const root = await Factories.Root.create(
|
||||
{ message: { rootBlock: 100, username: 'alice' } },
|
||||
{ transient: { privateKey: Faker.datatype.hexaDecimal(64).toLowerCase() } }
|
||||
);
|
||||
const result = engine.addRoot(root);
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result._unsafeUnwrapErr()).toBe('Invalid root');
|
||||
expect(subject()).toEqual([]);
|
||||
});
|
||||
|
||||
test('fails if the signer was valid before this block', async () => {
|
||||
const changeSigner = {
|
||||
blockNumber: 99,
|
||||
blockHash: Faker.datatype.hexaDecimal(64).toLowerCase(),
|
||||
logIndex: 1,
|
||||
address: Faker.datatype.hexaDecimal(40).toLowerCase(),
|
||||
};
|
||||
|
||||
engine.addSignerChange('alice', changeSigner);
|
||||
const result = engine.addRoot(rootA110);
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result._unsafeUnwrapErr()).toBe('Invalid root');
|
||||
expect(subject()).toEqual([]);
|
||||
});
|
||||
|
||||
test('fails if the signer was valid after this block', async () => {
|
||||
const result = engine.addRoot(rootC90);
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result._unsafeUnwrapErr()).toBe('Invalid root');
|
||||
expect(subject()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('fails without mutating state if Root is an invalid message', async () => {
|
||||
const root = await Factories.Root.create({ message: { rootBlock: 100, username: 'alice' } });
|
||||
const root = await Factories.Root.create(
|
||||
{ message: { rootBlock: 100, username: 'alice' } },
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
engine.addRoot(root);
|
||||
expect(subject()).toEqual([[root]]);
|
||||
|
||||
@@ -83,8 +162,11 @@ describe('addRoot', () => {
|
||||
expect(subject()).toEqual([[root]]);
|
||||
});
|
||||
|
||||
test('fails if the user is unknown', async () => {
|
||||
const root = await Factories.Root.create({ message: { rootBlock: 100, username: 'rob' } });
|
||||
test('fails if the username is unknown, even if the signer is known', async () => {
|
||||
const root = await Factories.Root.create(
|
||||
{ message: { rootBlock: 100, username: 'rob' } },
|
||||
{ transient: { privateKey: alicePrivateKey } }
|
||||
);
|
||||
const rootRes = engine.addRoot(root);
|
||||
|
||||
expect(rootRes.isOk()).toBe(false);
|
||||
@@ -197,3 +279,65 @@ describe('addRoot', () => {
|
||||
// TODO: Write tests once stitching is implemented.
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSignerChange', () => {
|
||||
// Change @charlie's signer at block 100.
|
||||
const signerChange: Signer = {
|
||||
address: Faker.datatype.hexaDecimal(40).toLowerCase(),
|
||||
blockHash: Faker.datatype.hexaDecimal(64).toLowerCase(),
|
||||
blockNumber: 100,
|
||||
logIndex: 12,
|
||||
};
|
||||
|
||||
// Change charlie's signer at block 200.
|
||||
const signerChange200 = JSON.parse(JSON.stringify(signerChange)) as Signer;
|
||||
signerChange200.blockHash = Faker.datatype.hexaDecimal(64).toLowerCase();
|
||||
signerChange200.blockNumber = signerChange.blockNumber + 100;
|
||||
|
||||
// Change charlie's signer at block 50.
|
||||
const signerChange50A = JSON.parse(JSON.stringify(signerChange)) as Signer;
|
||||
signerChange50A.blockHash = Faker.datatype.hexaDecimal(64).toLowerCase();
|
||||
signerChange50A.blockNumber = signerChange.blockNumber - 10;
|
||||
|
||||
// Change charlie's signer at block 50, at a higher index.
|
||||
const signerChange50B = JSON.parse(JSON.stringify(signerChange50A)) as Signer;
|
||||
signerChange50B.logIndex = signerChange.logIndex + 1;
|
||||
|
||||
const duplicateSignerChange50B = JSON.parse(JSON.stringify(signerChange50B)) as Signer;
|
||||
|
||||
const username = 'charlie';
|
||||
const subject = () => engine.getSigners(username);
|
||||
|
||||
test('signer changes are added correctly', async () => {
|
||||
const result = engine.addSignerChange(username, signerChange);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(subject()).toEqual([signerChange]);
|
||||
});
|
||||
|
||||
test('signer changes from later blocks are added after current blocks', async () => {
|
||||
const result = engine.addSignerChange(username, signerChange200);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(subject()).toEqual([signerChange, signerChange200]);
|
||||
});
|
||||
|
||||
test('signer changes from earlier blocks are before current blocks', async () => {
|
||||
const result = engine.addSignerChange(username, signerChange50A);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(subject()).toEqual([signerChange50A, signerChange, signerChange200]);
|
||||
});
|
||||
|
||||
test('signer changes in the same block are ordered by index', async () => {
|
||||
const result = engine.addSignerChange(username, signerChange50B);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(subject()).toEqual([signerChange50A, signerChange50B, signerChange, signerChange200]);
|
||||
});
|
||||
|
||||
test('adding a duplicate signer change fails', async () => {
|
||||
const result = engine.addSignerChange(username, duplicateSignerChange50B);
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result._unsafeUnwrapErr()).toBe(
|
||||
`addSignerChange: duplicate signer change ${signerChange50B.blockHash}:${signerChange50B.logIndex}`
|
||||
);
|
||||
expect(subject()).toEqual([signerChange50A, signerChange50B, signerChange, signerChange200]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,18 +11,29 @@ export interface ChainFingerprint {
|
||||
lastMessageHash: string | undefined;
|
||||
}
|
||||
|
||||
export interface Signer {
|
||||
address: string;
|
||||
blockHash: string;
|
||||
blockNumber: number;
|
||||
logIndex: number;
|
||||
}
|
||||
|
||||
/** The Engine receives messages and determines the current state Farcaster network */
|
||||
class Engine {
|
||||
/** Mapping of usernames to their casts, which are stored as a series of signed chains */
|
||||
private _castChains: Map<string, SignedCastChain[]>;
|
||||
private _validUsernames: Array<string>;
|
||||
private _users: Map<string, Signer[]>;
|
||||
|
||||
constructor() {
|
||||
this._castChains = new Map();
|
||||
this._validUsernames = ['alice'];
|
||||
this._users = new Map();
|
||||
}
|
||||
|
||||
getCastChains(username: string): SignedCastChain[] {
|
||||
getSigners(username: string): Signer[] | undefined {
|
||||
return this._users.get(username);
|
||||
}
|
||||
|
||||
getChains(username: string): SignedCastChain[] {
|
||||
const chainList = this._castChains.get(username);
|
||||
return chainList || [];
|
||||
}
|
||||
@@ -143,10 +154,43 @@ class Engine {
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
/** Add a new SignerChange event into the user's Signer Change array */
|
||||
addSignerChange(username: string, newSignerChange: Signer): Result<void, string> {
|
||||
const signerChanges = this._users.get(username);
|
||||
|
||||
if (!signerChanges) {
|
||||
this._users.set(username, [newSignerChange]);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
let signerIdx = 0;
|
||||
|
||||
// Insert the SignerChange into the array such that the array maintains ascending order
|
||||
// of blockNumbers, followed by logIndex (for changes that occur within the same block).
|
||||
for (const sc of signerChanges) {
|
||||
if (sc.blockNumber < newSignerChange.blockNumber) {
|
||||
signerIdx++;
|
||||
} else if (sc.blockNumber === newSignerChange.blockNumber) {
|
||||
if (sc.logIndex < newSignerChange.logIndex) {
|
||||
signerIdx++;
|
||||
} else if (sc.logIndex === newSignerChange.logIndex) {
|
||||
return err(`addSignerChange: duplicate signer change ${sc.blockHash}:${sc.logIndex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signerChanges.splice(signerIdx, 0, newSignerChange);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
resetChains(): void {
|
||||
this._castChains = new Map();
|
||||
}
|
||||
|
||||
resetUsers(): void {
|
||||
this._users = new Map();
|
||||
}
|
||||
|
||||
private validateMessageChain(message: SignedMessage, prevMessage?: SignedMessage): boolean {
|
||||
const newProps = message.message;
|
||||
|
||||
@@ -174,18 +218,39 @@ class Engine {
|
||||
return this.validateMessage(message);
|
||||
}
|
||||
|
||||
/** Determine the valid signer address for a username at a block */
|
||||
private signerForBlock(username: string, blockNumber: number): string | undefined {
|
||||
const signerChanges = this._users.get(username);
|
||||
|
||||
if (!signerChanges) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let signer = undefined;
|
||||
|
||||
for (const sc of signerChanges) {
|
||||
if (sc.blockNumber <= blockNumber) {
|
||||
signer = sc.address;
|
||||
}
|
||||
}
|
||||
|
||||
return signer;
|
||||
}
|
||||
|
||||
private validateMessage(message: SignedMessage): boolean {
|
||||
if (this._validUsernames.indexOf(message.message.username) === -1) {
|
||||
// 1. Check that the signer was valid for the block in question.
|
||||
const expectedSigner = this.signerForBlock(message.message.username, message.message.rootBlock);
|
||||
if (!expectedSigner || expectedSigner !== message.signer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that the hash value of the message was computed correctly.
|
||||
// 2. Check that the hash value of the message was computed correctly.
|
||||
const computedHash = hashMessage(message);
|
||||
if (message.hash !== computedHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that the signature is valid
|
||||
// 3. Check that the signature is valid
|
||||
const recoveredAddress = utils.recoverAddress(message.hash, message.signature);
|
||||
if (recoveredAddress !== message.signer) {
|
||||
return false;
|
||||
@@ -202,7 +267,6 @@ class Engine {
|
||||
// TODO: check that all required properties are present.
|
||||
// TODO: check that username is known to the registry
|
||||
// TODO: check that the signer is the owner of the username.
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,4 +83,25 @@ export const Factories = {
|
||||
signer: '',
|
||||
};
|
||||
}),
|
||||
|
||||
/** Generate a new ETH Address with its corresponding private key */
|
||||
EthAddress: Factory.define<EthAddress, any, EthAddress>(({ onCreate }) => {
|
||||
onCreate(async (addressProps) => {
|
||||
const wallet = new ethers.Wallet(addressProps.privateKey);
|
||||
addressProps.address = await wallet.getAddress();
|
||||
return addressProps;
|
||||
});
|
||||
|
||||
const privateKey = Faker.datatype.hexaDecimal(64).toLowerCase();
|
||||
|
||||
return {
|
||||
address: '',
|
||||
privateKey,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
interface EthAddress {
|
||||
address: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Cast, Root, SignedCastChain, SignedCastChainFragment, SignedMessage } from '~/types';
|
||||
import Engine, { ChainFingerprint } from '~/engine';
|
||||
import { isCast, isRoot } from '~/types/typeguards';
|
||||
import { Result } from 'neverthrow';
|
||||
|
||||
/** The Node brokers messages to clients and peers and passes new messages into the Engine for resolution */
|
||||
class FCNode {
|
||||
@@ -106,13 +107,13 @@ class FCNode {
|
||||
*/
|
||||
|
||||
/** Start a new chain for the user */
|
||||
addRoot(root: Root): void {
|
||||
this.engine.addRoot(root);
|
||||
addRoot(root: Root): Result<void, string> {
|
||||
return this.engine.addRoot(root);
|
||||
}
|
||||
|
||||
/** Merge a single message into the latest chain */
|
||||
addCast(Cast: Cast): void {
|
||||
this.engine.addCast(Cast);
|
||||
return this.engine.addCast(Cast);
|
||||
}
|
||||
|
||||
/** Merge a partial chain into the latest chain */
|
||||
|
||||
@@ -26,37 +26,47 @@ if (!knightNode) {
|
||||
exit();
|
||||
}
|
||||
|
||||
// 4. Set up a Client
|
||||
Debugger.printState();
|
||||
|
||||
// 4. Set up a Client to generate messages
|
||||
const client = new Client('alice');
|
||||
|
||||
const signerChange = {
|
||||
blockNumber: 99,
|
||||
blockHash: Faker.datatype.hexaDecimal(64).toLowerCase(),
|
||||
logIndex: 0,
|
||||
address: client.address,
|
||||
};
|
||||
// In this step, we'd make each node listen to the registry for username registrations
|
||||
// and signer changes. For now, we take a shortcut and just tell the engine that a
|
||||
// registration has occured for alice.
|
||||
console.log(`Farcaster Registry: @alice was registered by ${client.address}`);
|
||||
for (const node of nodeList.values()) {
|
||||
node.engine.addSignerChange('alice', signerChange);
|
||||
}
|
||||
|
||||
// 5. Send two messages, sequentially to the node.
|
||||
const m1 = client.generateRoot(0, Faker.datatype.hexaDecimal(64).toLowerCase());
|
||||
console.log('Farcaster Client: @alice is starting a new chain');
|
||||
const m1 = client.generateRoot(signerChange.blockNumber, signerChange.blockHash);
|
||||
knightNode.addRoot(m1);
|
||||
|
||||
let lastMsg = knightNode.getLastMessage(client.username);
|
||||
if (lastMsg) {
|
||||
if (isCast(lastMsg) || isRoot(lastMsg)) {
|
||||
const m2 = client.generateCast('Hello, world!', lastMsg);
|
||||
knightNode.addCast(m2);
|
||||
}
|
||||
if (lastMsg && isRoot(lastMsg)) {
|
||||
console.log('Farcaster Client: @alice is casting one message');
|
||||
const m2 = client.generateCast('Hello, world!', lastMsg);
|
||||
knightNode.addCast(m2);
|
||||
}
|
||||
|
||||
Debugger.printState();
|
||||
|
||||
// 6. Send multiple messages to the node.
|
||||
console.log('Client: adding two message into the chain');
|
||||
console.log('Farcaster Client: @alice is casting two new messages');
|
||||
lastMsg = knightNode.getLastMessage(client.username);
|
||||
if (lastMsg) {
|
||||
if (isCast(lastMsg) || isRoot(lastMsg)) {
|
||||
const m3 = client.generateCast("I'm a cast!", lastMsg);
|
||||
const m4 = client.generateCast('On another chain!', m3);
|
||||
const chain = [m3, m4];
|
||||
knightNode.addChain(chain);
|
||||
}
|
||||
if (lastMsg && isCast(lastMsg)) {
|
||||
const m3 = client.generateCast("I'm a cast!", lastMsg);
|
||||
const m4 = client.generateCast('On another chain!', m3);
|
||||
const chain = [m3, m4];
|
||||
knightNode.addChain(chain);
|
||||
}
|
||||
|
||||
Debugger.printState();
|
||||
|
||||
// 7. Start syncing all nodes at random intervals.
|
||||
for (const node of nodeList.values()) {
|
||||
node.sync();
|
||||
@@ -66,13 +76,23 @@ setInterval(() => {
|
||||
Debugger.printState();
|
||||
}, 5_000);
|
||||
|
||||
// 8. Start another chain and send it to the node.
|
||||
console.log('Client: starting a new chain');
|
||||
|
||||
// 8. @alice changes her address and starts a new chain.
|
||||
setTimeout(() => {
|
||||
Debugger.printState();
|
||||
const b1 = client.generateRoot(1, Faker.datatype.hexaDecimal(64).toLowerCase(), m1.message.body.blockHash);
|
||||
console.log('Farcaster Client: @alice is changing signers');
|
||||
const client2 = new Client('alice');
|
||||
|
||||
const signerChange = {
|
||||
blockNumber: 100,
|
||||
blockHash: Faker.datatype.hexaDecimal(64).toLowerCase(),
|
||||
logIndex: 0,
|
||||
address: client2.address,
|
||||
};
|
||||
|
||||
for (const node of nodeList.values()) {
|
||||
node.engine.addSignerChange('alice', signerChange);
|
||||
}
|
||||
|
||||
console.log('Farcaster Client: @alice is starting a new chain');
|
||||
const b1 = client2.generateRoot(signerChange.blockNumber, signerChange.blockHash, m1.message.body.blockHash);
|
||||
knightNode.addRoot(b1);
|
||||
}, 30_000);
|
||||
|
||||
Debugger.printState();
|
||||
|
||||
Reference in New Issue
Block a user