feat: engine tracks signers for usernames (#11)

This commit is contained in:
Varun Srinivasan
2022-03-24 20:27:05 -07:00
committed by GitHub
parent a004ab4866
commit 1a03ba0f1d
7 changed files with 329 additions and 75 deletions

View File

@@ -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,

View File

@@ -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) });
}
}

View File

@@ -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]);
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 */

View File

@@ -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();