diff --git a/.changeset/serious-bulldogs-sort.md b/.changeset/serious-bulldogs-sort.md new file mode 100644 index 0000000000..6c45823776 --- /dev/null +++ b/.changeset/serious-bulldogs-sort.md @@ -0,0 +1,8 @@ +--- +"@farcaster/hub-nodejs": patch +"@farcaster/hub-web": patch +"@farcaster/core": patch +"@farcaster/hubble": patch +--- + +feat: add user location to the protocol diff --git a/apps/hubble/src/rpc/test/userDataService.test.ts b/apps/hubble/src/rpc/test/userDataService.test.ts index 634729645e..ec7f3b5eb3 100644 --- a/apps/hubble/src/rpc/test/userDataService.test.ts +++ b/apps/hubble/src/rpc/test/userDataService.test.ts @@ -7,6 +7,8 @@ import { getInsecureHubRpcClient, HubError, HubRpcClient, + isUserDataAddData, + makeUserDataAdd, Message, OnChainEvent, UserDataAddMessage, @@ -68,6 +70,8 @@ let addFname: UserDataAddMessage; let ensNameProof: UsernameProofMessage; let addEnsName: UserDataAddMessage; +let locationAdd: UserDataAddMessage; + beforeAll(async () => { const signerKey = (await signer.getSignerKey())._unsafeUnwrap(); custodySignerKey = (await custodySigner.getSignerKey())._unsafeUnwrap(); @@ -99,6 +103,20 @@ beforeAll(async () => { { transient: { signer } }, ); + locationAdd = await Factories.UserDataAddMessage.create( + { + data: { + fid, + userDataBody: { + type: UserDataType.LOCATION, + value: "geo:23.45,-167.78", + }, + timestamp: pfpAdd.data.timestamp + 2, + }, + }, + { transient: { signer } }, + ); + const custodySignerAddress = bytesToHexString(custodySignerKey)._unsafeUnwrap(); jest.spyOn(publicClient, "getEnsAddress").mockImplementation(() => { @@ -169,6 +187,35 @@ describe("getUserData", () => { expect(await engine.mergeMessage(addEnsName)).toBeInstanceOf(Ok); const ensNameData = await client.getUserData(UserDataRequest.create({ fid, userDataType: UserDataType.USERNAME })); expect(Message.toJSON(ensNameData._unsafeUnwrap())).toEqual(Message.toJSON(addEnsName)); + + expect(await engine.mergeMessage(locationAdd)).toBeInstanceOf(Ok); + const location = await client.getUserData(UserDataRequest.create({ fid, userDataType: UserDataType.LOCATION })); + expect(Message.toJSON(location._unsafeUnwrap())).toEqual(Message.toJSON(locationAdd)); + }); + + test("user location data can be cleared", async () => { + expect(await engine.mergeMessage(locationAdd)).toBeInstanceOf(Ok); + const location = await client.getUserData(UserDataRequest.create({ fid, userDataType: UserDataType.LOCATION })); + expect(Message.toJSON(location._unsafeUnwrap())).toEqual(Message.toJSON(locationAdd)); + makeUserDataAdd; + const emptyLocationAdd = await Factories.UserDataAddMessage.create( + { + data: { + fid, + userDataBody: { + type: UserDataType.LOCATION, + value: "", + }, + timestamp: locationAdd.data.timestamp + 1, + }, + }, + { transient: { signer } }, + ); + expect(await engine.mergeMessage(emptyLocationAdd)).toBeInstanceOf(Ok); + const emptyLocation = await client.getUserData( + UserDataRequest.create({ fid, userDataType: UserDataType.LOCATION }), + ); + expect(Message.toJSON(emptyLocation._unsafeUnwrap())).toEqual(Message.toJSON(emptyLocationAdd)); }); test("fails when user data is missing", async () => { diff --git a/packages/core/src/protobufs/generated/message.ts b/packages/core/src/protobufs/generated/message.ts index 3bc697599b..fb3b7ad783 100644 --- a/packages/core/src/protobufs/generated/message.ts +++ b/packages/core/src/protobufs/generated/message.ts @@ -242,6 +242,8 @@ export enum UserDataType { URL = 5, /** USERNAME - Preferred Name for the user */ USERNAME = 6, + /** LOCATION - Current location for the user */ + LOCATION = 7, } export function userDataTypeFromJSON(object: any): UserDataType { @@ -264,6 +266,9 @@ export function userDataTypeFromJSON(object: any): UserDataType { case 6: case "USER_DATA_TYPE_USERNAME": return UserDataType.USERNAME; + case 7: + case "USER_DATA_TYPE_LOCATION": + return UserDataType.LOCATION; default: throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType"); } @@ -283,6 +288,8 @@ export function userDataTypeToJSON(object: UserDataType): string { return "USER_DATA_TYPE_URL"; case UserDataType.USERNAME: return "USER_DATA_TYPE_USERNAME"; + case UserDataType.LOCATION: + return "USER_DATA_TYPE_LOCATION"; default: throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType"); } diff --git a/packages/core/src/validations.test.ts b/packages/core/src/validations.test.ts index c586e4d58f..2e0cc6fe5d 100644 --- a/packages/core/src/validations.test.ts +++ b/packages/core/src/validations.test.ts @@ -1042,6 +1042,30 @@ describe("validateUserDataAddBody", () => { expect(validations.validateUserDataAddBody(body)).toEqual(ok(body)); }); + test("succeeds for empty location", async () => { + const body = Factories.UserDataBody.build({ + type: UserDataType.LOCATION, + value: "", + }); + expect(validations.validateUserDataAddBody(body)).toEqual(ok(body)); + }); + + test("succeeds for valid location: negative longitude", async () => { + const body = Factories.UserDataBody.build({ + type: UserDataType.LOCATION, + value: "geo:12.34,-123.45", + }); + expect(validations.validateUserDataAddBody(body)).toEqual(ok(body)); + }); + + test("succeeds for valid location: negative latitude", async () => { + const body = Factories.UserDataBody.build({ + type: UserDataType.LOCATION, + value: "geo:-12.34,123.45", + }); + expect(validations.validateUserDataAddBody(body)).toEqual(ok(body)); + }); + describe("fails", () => { let body: protobufs.UserDataBody; let hubErrorMessage: string; @@ -1083,6 +1107,118 @@ describe("validateUserDataAddBody", () => { }); hubErrorMessage = "url value > 256"; }); + + test("when latitude is too low", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:-90.01,12.34", + }); + hubErrorMessage = "Latitude value outside valid range"; + }); + + test("when latitude is too high", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:90.01,12.34", + }); + hubErrorMessage = "Latitude value outside valid range"; + }); + + test("when longitude is too low", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34,-180.01", + }); + hubErrorMessage = "Longitude value outside valid range"; + }); + + test("when longitude is too high", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34,180.01", + }); + hubErrorMessage = "Longitude value outside valid range"; + }); + + test("when latitude has too much precision", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.345,12.34", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when latitude has insufficient precision", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12,12.34", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when longitude has too much precision", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34,12.345", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when longitude has insufficient precision", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34,12", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when latitude is an invalid number", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:xx,12.34", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when longitude is an invalid number", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34,xx", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when location is missing geo prefix", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "12.34,12.34", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when location is missing both coordinates", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when location is missing a coordinate", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34,", + }); + hubErrorMessage = "Invalid location string"; + }); + + test("when location contains a space", () => { + body = Factories.UserDataBody.build({ + type: protobufs.UserDataType.LOCATION, + value: "geo:12.34, 12.34", + }); + hubErrorMessage = "Invalid location string"; + }); }); }); diff --git a/packages/core/src/validations.ts b/packages/core/src/validations.ts index c9fa002514..edf7535e61 100644 --- a/packages/core/src/validations.ts +++ b/packages/core/src/validations.ts @@ -110,6 +110,77 @@ export const validateMessageHash = (hash?: Uint8Array): HubResult => return ok(hash); }; +const validateNumber = (value: string) => { + const number = parseFloat(value); + if (Number.isNaN(number)) { + return err(undefined); + } + return ok(number); +}; + +const validateLatitude = (value: string) => { + const number = validateNumber(value); + + if (number.isErr()) { + return err(new HubError("bad_request.validation_failure", "Latitude is not a valid number")); + } + + if (number.value < -90 || number.value > 90) { + return err(new HubError("bad_request.validation_failure", "Latitude value outside valid range")); + } + + return ok(value); +}; + +const validateLongitude = (value: string) => { + const number = validateNumber(value); + + if (number.isErr()) { + return err(new HubError("bad_request.validation_failure", "Longitude is not a valid number")); + } + + if (number.value < -180 || number.value > 180) { + return err(new HubError("bad_request.validation_failure", "Longitude value outside valid range")); + } + + return ok(value); +}; + +// Expected format is [geo:,}] +export const validateUserLocation = (location: string) => { + if (location === "") { + // This is to support clearing location + return ok(location); + } + + // Match any <=2 digit number with 2dp for latitude and <=3 digit number with 3dp for longitude. Perform validation on ranges in code after parsing because doing this in regex is pretty cumbersome. + const result = location.match(/^geo:(-?\d{1,2}\.\d{2}),(-?\d{1,3}\.\d{2})$/); + + if (result === null || result[0] !== location) { + return err(new HubError("bad_request.validation_failure", "Invalid location string")); + } + + if (result[1] === undefined) { + return err(new HubError("bad_request.validation_failure", "Location missing latitude")); + } + + const latitude = validateLatitude(result[1]); + if (latitude.isErr()) { + return err(latitude.error); + } + + if (result[2] === undefined) { + return err(new HubError("bad_request.validation_failure", "Location missing longitude")); + } + + const longitude = validateLongitude(result[2]); + if (longitude.isErr()) { + return err(longitude.error); + } + + return ok(location); +}; + export const validateCastId = (castId?: protobufs.CastId): HubResult => { if (!castId) { return err(new HubError("bad_request.validation_failure", "castId is missing")); @@ -944,6 +1015,13 @@ export const validateUserDataAddBody = (body: protobufs.UserDataBody): HubResult } break; } + case protobufs.UserDataType.LOCATION: { + const validatedUserLocation = validateUserLocation(value); + if (validatedUserLocation.isErr()) { + return err(validatedUserLocation.error); + } + break; + } default: return err(new HubError("bad_request.validation_failure", "invalid user data type")); } diff --git a/packages/hub-nodejs/src/generated/message.ts b/packages/hub-nodejs/src/generated/message.ts index 3bc697599b..fb3b7ad783 100644 --- a/packages/hub-nodejs/src/generated/message.ts +++ b/packages/hub-nodejs/src/generated/message.ts @@ -242,6 +242,8 @@ export enum UserDataType { URL = 5, /** USERNAME - Preferred Name for the user */ USERNAME = 6, + /** LOCATION - Current location for the user */ + LOCATION = 7, } export function userDataTypeFromJSON(object: any): UserDataType { @@ -264,6 +266,9 @@ export function userDataTypeFromJSON(object: any): UserDataType { case 6: case "USER_DATA_TYPE_USERNAME": return UserDataType.USERNAME; + case 7: + case "USER_DATA_TYPE_LOCATION": + return UserDataType.LOCATION; default: throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType"); } @@ -283,6 +288,8 @@ export function userDataTypeToJSON(object: UserDataType): string { return "USER_DATA_TYPE_URL"; case UserDataType.USERNAME: return "USER_DATA_TYPE_USERNAME"; + case UserDataType.LOCATION: + return "USER_DATA_TYPE_LOCATION"; default: throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType"); } diff --git a/packages/hub-web/src/generated/message.ts b/packages/hub-web/src/generated/message.ts index 3bc697599b..fb3b7ad783 100644 --- a/packages/hub-web/src/generated/message.ts +++ b/packages/hub-web/src/generated/message.ts @@ -242,6 +242,8 @@ export enum UserDataType { URL = 5, /** USERNAME - Preferred Name for the user */ USERNAME = 6, + /** LOCATION - Current location for the user */ + LOCATION = 7, } export function userDataTypeFromJSON(object: any): UserDataType { @@ -264,6 +266,9 @@ export function userDataTypeFromJSON(object: any): UserDataType { case 6: case "USER_DATA_TYPE_USERNAME": return UserDataType.USERNAME; + case 7: + case "USER_DATA_TYPE_LOCATION": + return UserDataType.LOCATION; default: throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType"); } @@ -283,6 +288,8 @@ export function userDataTypeToJSON(object: UserDataType): string { return "USER_DATA_TYPE_URL"; case UserDataType.USERNAME: return "USER_DATA_TYPE_USERNAME"; + case UserDataType.LOCATION: + return "USER_DATA_TYPE_LOCATION"; default: throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType"); } diff --git a/protobufs/schemas/message.proto b/protobufs/schemas/message.proto index b5f10d44f5..1ab1651345 100644 --- a/protobufs/schemas/message.proto +++ b/protobufs/schemas/message.proto @@ -97,6 +97,7 @@ enum UserDataType { USER_DATA_TYPE_BIO = 3; // Bio for the user USER_DATA_TYPE_URL = 5; // URL of the user USER_DATA_TYPE_USERNAME = 6; // Preferred Name for the user + USER_DATA_TYPE_LOCATION = 7; // Current location for the user } message Embed {