Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for user location in the protocol #2365

Merged
merged 9 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
case 6:
case "USER_DATA_TYPE_USERNAME":
return UserDataType.USERNAME;
case 7:
case "USER_DATA_TYPE_LOCATION":
return UserDataType.LOCATION;

Check warning on line 271 in packages/core/src/protobufs/generated/message.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/protobufs/generated/message.ts#L269-L271

Added lines #L269 - L271 were not covered by tests
default:
throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType");
}
Expand All @@ -283,6 +288,8 @@
return "USER_DATA_TYPE_URL";
case UserDataType.USERNAME:
return "USER_DATA_TYPE_USERNAME";
case UserDataType.LOCATION:
return "USER_DATA_TYPE_LOCATION";

Check warning on line 292 in packages/core/src/protobufs/generated/message.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/protobufs/generated/message.ts#L291-L292

Added lines #L291 - L292 were not covered by tests
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 @@
return ok(hash);
};

const validateNumber = (value: string) => {
const number = parseFloat(value);
if (Number.isNaN(number)) {
return err(undefined);

Check warning on line 116 in packages/core/src/validations.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/validations.ts#L116

Added line #L116 was not covered by tests
}
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"));

Check warning on line 125 in packages/core/src/validations.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/validations.ts#L125

Added line #L125 was not covered by tests
}

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"));

Check warning on line 139 in packages/core/src/validations.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/validations.ts#L139

Added line #L139 was not covered by tests
}

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"));

Check warning on line 164 in packages/core/src/validations.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/validations.ts#L164

Added line #L164 was not covered by tests
}

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"));

Check warning on line 173 in packages/core/src/validations.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/validations.ts#L173

Added line #L173 was not covered by tests
}

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 @@
}
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 @@
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 @@
case 6:
case "USER_DATA_TYPE_USERNAME":
return UserDataType.USERNAME;
case 7:
case "USER_DATA_TYPE_LOCATION":
return UserDataType.LOCATION;

Check warning on line 271 in packages/hub-nodejs/src/generated/message.ts

View check run for this annotation

Codecov / codecov/patch

packages/hub-nodejs/src/generated/message.ts#L269-L271

Added lines #L269 - L271 were not covered by tests
default:
throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType");
}
Expand All @@ -283,6 +288,8 @@
return "USER_DATA_TYPE_URL";
case UserDataType.USERNAME:
return "USER_DATA_TYPE_USERNAME";
case UserDataType.LOCATION:
return "USER_DATA_TYPE_LOCATION";

Check warning on line 292 in packages/hub-nodejs/src/generated/message.ts

View check run for this annotation

Codecov / codecov/patch

packages/hub-nodejs/src/generated/message.ts#L291-L292

Added lines #L291 - L292 were not covered by tests
default:
throw new tsProtoGlobalThis.Error("Unrecognized enum value " + object + " for enum UserDataType");
}
Expand Down
Loading
Loading