Skip to content

Commit

Permalink
feat: add support for user location in the protocol (#2365)
Browse files Browse the repository at this point in the history
This is required to decentralize user location. 

## Merge Checklist

_Choose all relevant options below by adding an `x` now or at any time
before submitting for review_

- [x] PR title adheres to the [conventional
commits](https://www.conventionalcommits.org/en/v1.0.0/) standard
- [x] PR has a
[changeset](https://github.com/farcasterxyz/hub-monorepo/blob/main/CONTRIBUTING.md#35-adding-changesets)
- [x] PR has been tagged with a change label(s) (i.e. documentation,
feature, bugfix, or chore)
- [ ] PR includes
[documentation](https://github.com/farcasterxyz/hub-monorepo/blob/main/CONTRIBUTING.md#32-writing-docs)
if necessary.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new feature to add user location data to the
protocol, including validation for latitude and longitude, and updates
across several files to support this functionality.

### Detailed summary
- Added `USER_DATA_TYPE_LOCATION` to `protobufs/schemas/message.proto`.
- Updated `userDataTypeFromJSON` and `userDataTypeToJSON` functions to
handle location type.
- Implemented location handling in `packages/hub-web`,
`packages/hub-nodejs`, and `packages/core`.
- Created validation functions for latitude and longitude.
- Added tests for user location data handling in
`apps/hubble/src/rpc/test/userDataService.test.ts` and
`packages/core/src/validations.test.ts`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your
question}`

<!-- end pr-codex -->
  • Loading branch information
aditiharini authored Oct 12, 2024
1 parent 0b79a8a commit aa9cde7
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .changeset/serious-bulldogs-sort.md
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions apps/hubble/src/rpc/test/userDataService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
getInsecureHubRpcClient,
HubError,
HubRpcClient,
isUserDataAddData,
makeUserDataAdd,
Message,
OnChainEvent,
UserDataAddMessage,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/protobufs/generated/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand Down
136 changes: 136 additions & 0 deletions packages/core/src/validations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
});
});
});

Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,77 @@ export const validateMessageHash = (hash?: Uint8Array): HubResult<Uint8Array> =>
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:<lat>,<long>}]
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<protobufs.CastId> => {
if (!castId) {
return err(new HubError("bad_request.validation_failure", "castId is missing"));
Expand Down Expand Up @@ -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"));
}
Expand Down
7 changes: 7 additions & 0 deletions packages/hub-nodejs/src/generated/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand Down
Loading

0 comments on commit aa9cde7

Please sign in to comment.