Skip to content

Commit

Permalink
feat: create FrameAction message and add validation endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
sanjayprabhu committed Jan 25, 2024
1 parent f385961 commit e9537a4
Show file tree
Hide file tree
Showing 29 changed files with 1,022 additions and 5 deletions.
9 changes: 9 additions & 0 deletions .changeset/purple-hornets-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@farcaster/hub-nodejs": patch
"@farcaster/hub-web": patch
"@farcaster/replicator": patch
"@farcaster/core": patch
"@farcaster/hubble": patch
---

feat: Add support for FrameAction and validateMessage
5 changes: 5 additions & 0 deletions apps/hubble/src/hubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export interface HubInterface {
identity: string;
hubOperatorFid?: number;
submitMessage(message: Message, source?: HubSubmitSource): HubAsyncResult<number>;
validateMessage(message: Message): HubAsyncResult<Message>;
submitUserNameProof(usernameProof: UserNameProof, source?: HubSubmitSource): HubAsyncResult<number>;
submitOnChainEvent(event: OnChainEvent, source?: HubSubmitSource): HubAsyncResult<number>;
getHubState(): HubAsyncResult<HubState>;
Expand Down Expand Up @@ -1370,6 +1371,10 @@ export class Hub implements HubInterface {
return mergeResult;
}

async validateMessage(message: Message): HubAsyncResult<Message> {
return this.engine.validateMessage(message);
}

async submitUserNameProof(usernameProof: UserNameProof, source?: HubSubmitSource): HubAsyncResult<number> {
const logEvent = log.child({
event: usernameProofToLog(usernameProof),
Expand Down
37 changes: 37 additions & 0 deletions apps/hubble/src/rpc/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
sendUnaryData,
userDataTypeFromJSON,
utf8StringToBytes,
ValidationResponse,
} from "@farcaster/hub-nodejs";
import { Metadata, ServerUnaryCall } from "@grpc/grpc-js";
import fastify from "fastify";
Expand Down Expand Up @@ -571,6 +572,42 @@ export class HttpAPIServer {
this.grpcImpl.submitMessage(call, handleResponse(reply, Message));
});

//==================Validate Message==================
// @doc-tag: /validateMessage
this.app.post<{ Body: Buffer }>("/v1/validateMessage", (request, reply) => {
// Get the Body content-type
const contentType = request.headers["content-type"] as string;

let message;
if (contentType === "application/octet-stream") {
// The Posted Body is a serialized Message protobuf
const parsedMessage = Result.fromThrowable(
() => Message.decode(request.body),
(e) => e as Error,
)();

if (parsedMessage.isErr()) {
reply.code(400).send({
error:
"Could not parse Message. This API accepts only Message protobufs encoded into bytes (application/octet-stream)",
errorDetail: parsedMessage.error.message,
});
return;
} else {
message = parsedMessage.value;
}
} else {
reply.code(400).send({
error: "Unsupported Media Type",
errorDetail: `Content-Type ${contentType} is not supported`,
});
return;
}

const call = getCallObject("validateMessage", message, request);
this.grpcImpl.validateMessage(call, handleResponse(reply, ValidationResponse));
});

//==================Events==================
// @doc-tag: /eventById?event_id=...
this.app.get<{ Querystring: { event_id: string } }>("/v1/eventById", (request, reply) => {
Expand Down
13 changes: 13 additions & 0 deletions apps/hubble/src/rpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
UserDataAddMessage,
VerificationAddEthAddressMessage,
VerificationRemoveMessage,
ValidationResponse,
UserNameProof,
UsernameProofsResponse,
OnChainEventResponse,
Expand Down Expand Up @@ -578,6 +579,18 @@ export default class Server {
},
);
},
validateMessage: async (call, callback) => {
const message = call.request;
const result = await this.hub?.validateMessage(message);
result?.match(
(message: Message) => {
callback(null, ValidationResponse.create({ valid: true, message }));
},
(err: HubError) => {
callback(toServiceError(err));
},
);
},
getCast: async (call, callback) => {
const peer = Result.fromThrowable(() => call.getPeer())().unwrapOr("unknown");
log.debug({ method: "getCast", req: call.request }, `RPC call from ${peer}`);
Expand Down
21 changes: 21 additions & 0 deletions apps/hubble/src/rpc/test/httpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
UsernameProofMessage,
UserNameType,
utf8StringToBytes,
ValidationResponse,
VerificationAddEthAddressMessage,
} from "@farcaster/hub-nodejs";
import Engine from "../../storage/engine/index.js";
Expand Down Expand Up @@ -197,6 +198,26 @@ describe("httpServer", () => {
});
});

describe("validateMessage", () => {
test("succeeds", async () => {
const frameAction = await Factories.FrameActionMessage.create(
{ data: { fid, network } },
{ transient: { signer } },
);
const postConfig = { headers: { "Content-Type": "application/octet-stream" } };
const url = getFullUrl("/v1/validateMessage");

// Encode the message into a Buffer (of bytes)
const messageBytes = Buffer.from(Message.encode(frameAction).finish());
const response = await axios.post(url, messageBytes, postConfig);

expect(response.status).toBe(200);
expect(response.data).toEqual(
protoToJSON(ValidationResponse.create({ valid: true, message: frameAction }), ValidationResponse),
);
});
});

describe("HubEvents APIs", () => {
let castAdd: CastAddMessage;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Message,
CastId,
OnChainEvent,
ValidationResponse,
} from "@farcaster/hub-nodejs";
import { err } from "neverthrow";
import SyncEngine from "../../network/sync/syncEngine.js";
Expand Down Expand Up @@ -48,6 +49,7 @@ let signerEvent: OnChainEvent;
let storageEvent: OnChainEvent;
let castAdd: Message;
let castRemove: Message;
let frameAction: Message;

beforeAll(async () => {
const signerKey = (await signer.getSignerKey())._unsafeUnwrap();
Expand All @@ -62,6 +64,8 @@ beforeAll(async () => {
{ data: { fid, network, castRemoveBody: { targetHash: castAdd.hash } } },
{ transient: { signer } },
);

frameAction = await Factories.FrameActionMessage.create({ data: { fid, network } }, { transient: { signer } });
});

describe("submitMessage", () => {
Expand All @@ -84,6 +88,13 @@ describe("submitMessage", () => {
const result = await client.submitMessage(castAdd);
expect(result).toEqual(err(new HubError("bad_request.conflict", "message conflicts with a CastRemove")));
});

test("fails for frame action", async () => {
const result = await client.submitMessage(frameAction);
const err = result._unsafeUnwrapErr();
expect(err.errCode).toEqual("bad_request.validation_failure");
expect(err.message).toMatch("invalid message type");
});
});

test("fails without signer", async () => {
Expand All @@ -93,3 +104,37 @@ describe("submitMessage", () => {
expect(err.message).toMatch("unknown fid");
});
});

describe("validateMessage", () => {
describe("with signer", () => {
beforeEach(async () => {
await engine.mergeOnChainEvent(custodyEvent);
await engine.mergeOnChainEvent(signerEvent);
await engine.mergeOnChainEvent(storageEvent);
});

test("succeeds", async () => {
const castResult = await client.validateMessage(castAdd);
expect(ValidationResponse.toJSON(castResult._unsafeUnwrap())).toEqual(
ValidationResponse.toJSON(ValidationResponse.create({ valid: true, message: castAdd })),
);

const frameResult = await client.validateMessage(frameAction);
expect(ValidationResponse.toJSON(frameResult._unsafeUnwrap())).toEqual(
ValidationResponse.toJSON(ValidationResponse.create({ valid: true, message: frameAction })),
);
});
});

test("fails without signer", async () => {
const castResult = await client.submitMessage(castAdd);
const castErr = castResult._unsafeUnwrapErr();
expect(castErr.errCode).toEqual("bad_request.validation_failure");
expect(castErr.message).toMatch("unknown fid");

const frameResult = await client.submitMessage(frameAction);
const frameErr = frameResult._unsafeUnwrapErr();
expect(frameErr.errCode).toEqual("bad_request.validation_failure");
expect(frameErr.message).toMatch("unknown fid");
});
});
4 changes: 2 additions & 2 deletions apps/hubble/src/storage/db/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const unpackTsHash = (tsHash: Uint8Array): HubResult<[number, Uint8Array]
return ok([Buffer.from(timestamp).readUInt32BE(0), hash]);
};

export const typeToSetPostfix = (type: MessageType): UserMessagePostfix => {
export const typeToSetPostfix = (type: MessageType): UserMessagePostfix | null => {
if (type === MessageType.CAST_ADD || type === MessageType.CAST_REMOVE) {
return UserPostfix.CastMessage;
}
Expand All @@ -116,7 +116,7 @@ export const typeToSetPostfix = (type: MessageType): UserMessagePostfix => {
return UserPostfix.UsernameProofMessage;
}

throw new Error("invalid type");
return null;
};

export const putMessage = (db: RocksDB, message: Message): Promise<void> => {
Expand Down
33 changes: 33 additions & 0 deletions apps/hubble/src/storage/engine/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,39 @@ describe("mergeMessage", () => {
await mainnetEngine.stop();
});

describe("FrameAction messages", () => {
// These messages types are not intended to be persisted to the hub
test("even valid frame action messages cannot be merged", async () => {
await engine.mergeOnChainEvent(custodyEvent);
await engine.mergeOnChainEvent(signerAddEvent);
await engine.mergeOnChainEvent(storageEvent);

const frameAction = await Factories.FrameActionMessage.create(
{ data: { fid, network } },
{ transient: { signer } },
);
const validationResult = await engine.validateMessage(frameAction);
expect(validationResult.isOk()).toBeTruthy();
const result = await engine.mergeMessage(frameAction);
expect(result).toMatchObject(err({ errCode: "bad_request.validation_failure" }));
expect(result._unsafeUnwrapErr().message).toMatch("invalid message type");
});

test("validation fails correctly for invalid users", async () => {
const frameAction = await Factories.FrameActionMessage.create(
{ data: { fid, network } },
{ transient: { signer } },
);
let result = await engine.validateMessage(frameAction);
expect(result._unsafeUnwrapErr().message).toMatch("unknown fid");

await engine.mergeOnChainEvent(custodyEvent);

result = await engine.validateMessage(frameAction);
expect(result._unsafeUnwrapErr().message).toMatch("invalid signer");
});
});

describe("UsernameProof messages", () => {
const randomEthAddress = bytesToHexString(Factories.EthAddress.build())._unsafeUnwrap();
let usernameProofEvents: MergeUserNameProofBody[] = [];
Expand Down
4 changes: 4 additions & 0 deletions apps/hubble/src/test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class MockHub implements HubInterface {
return result;
}

async validateMessage(message: Message): HubAsyncResult<Message> {
return this.engine.validateMessage(message);
}

async submitUserNameProof(proof: UserNameProof): HubAsyncResult<number> {
return this.engine.mergeUserNameProof(proof);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@


# SubmitMessage API
The SubmitMessage API lets you submit signed Farcaster protocol messages to the Hub. Note that the message has to be sent as the encoded bytestream of the protobuf (`Message.encode(msg).finish()` in typescript), as POST data to the endpoint.
# Message API
The Message API lets you validate and submit signed Farcaster protocol messages to the Hub. Note that the message has to be sent as the encoded bytestream of the protobuf (`Message.encode(msg).finish()` in typescript), as POST data to the endpoint.

The encoding of the POST data has to be set to `application/octet-stream`. The endpoint returns the Message object as JSON if it was successfully submitted
The encoding of the POST data has to be set to `application/octet-stream`. The endpoint returns the Message object as JSON if it was successfully submitted or validated

## submitMessage
Submit a signed protobuf-serialized message to the Hub
Expand Down Expand Up @@ -84,6 +84,53 @@ try {
}
```

## validateMessage
Validate a signed protobuf-serialized message with the Hub. This can be used to verify that the hub will consider the
message valid. Or to validate message that cannot be submitted (e.g. Frame actions)

**Query Parameters**
| Parameter | Description | Example |
| --------- | ----------- | ------- |
| | This endpoint accepts no parameters | |


**Example**
```bash
curl -X POST "http://127.0.0.1:2281/v1/validateMessage" \
-H "Content-Type: application/octet-stream" \
--data-binary "@message.encoded.protobuf"

```


**Response**
```json
{
"valid": true,
"message": {
"data": {
"type": "MESSAGE_TYPE_FRAME_ACTION",
"fid": 2,
"timestamp": 48994466,
"network": "FARCASTER_NETWORK_MAINNET",
"frameActionBody": {
"url": "https://fcpolls.com/polls/1",
"buttonId": "2",
"castId": {
"fid": 226,
"hash": "0xa48dd46161d8e57725f5e26e34ec19c13ff7f3b9"
}
}
},
"hash": "0xd2b1ddc6c88e865a33cb1a565e0058d757042974",
"hashScheme": "HASH_SCHEME_BLAKE3",
"signature": "3msLXzxB4eEYe...dHrY1vkxcPAA==",
"signatureScheme": "SIGNATURE_SCHEME_ED25519",
"signer": "0x78ff9a...58c"
}
}
```

## Using with Rust, Go or other programing languages
Messages need to be signed with a ED25519 signer belonging to the FID. If you are using a different programming language than Typescript, you can manually construct the `MessageData` object and serialize it to the `data_bytes` field of the message. Then, use the `data_bytes` to compute the `hash` and `signature`. Please see the [`rust-submitmessage` example](https://github.com/farcasterxyz/hub-monorepo/tree/main/packages/hub-web/examples) for more details

Expand Down
2 changes: 2 additions & 0 deletions apps/replicator/src/processors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export async function processMessage(
log.debug(`Processing UsernameProofMessage ${hash} (fid ${fid})`, { fid, hash });
await processUserNameProofMessage(message, operation, trx);
break;
case MessageType.FRAME_ACTION:
throw new AssertionError("Unexpected FRAME_ACTION message type");
case MessageType.NONE:
throw new AssertionError("Message contained no type");
default:
Expand Down
2 changes: 2 additions & 0 deletions apps/replicator/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ export function convertProtobufMessageBodyToJson(message: Message): MessageBodyJ
type,
} satisfies UsernameProofBodyJson;
}
case MessageType.FRAME_ACTION:
throw new AssertionError("Unexpected FRAME_ACTION message type");
case MessageType.NONE:
throw new AssertionError("Message has no type");
default:
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/builders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,19 @@ describe("username proof", () => {
});
});
});

describe("makeFrameAction", () => {
test("succeeds", async () => {
const message = await builders.makeFrameAction(
protobufs.FrameActionBody.create({
buttonId: Buffer.from("1"),
url: Buffer.from("https://example.com"),
castId: { fid, hash: Factories.MessageHash.build() },
}),
{ fid, network },
ed25519Signer,
);
const isValid = await validations.validateMessage(message._unsafeUnwrap());
expect(isValid.isOk()).toBeTruthy();
});
});
Loading

0 comments on commit e9537a4

Please sign in to comment.