-
Notifications
You must be signed in to change notification settings - Fork 1
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
Safe integration for profile updates #201
base: develop
Are you sure you want to change the base?
Safe integration for profile updates #201
Conversation
The user controller had a lot of repetetive code. This patch dry's this code up quite a bit and makes the high-level intent more apparent.
2d5d75f
to
6e50961
Compare
src/lib/users/UserUpsertStrategy.ts
Outdated
address: string, | ||
request: MultisigUpsertRequest | EOAUpsertRequest, | ||
): UserUpsertStrategy { | ||
if ("signature" in request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably fine here, as there are not many other types of UpsertRequest
that I can think of that might be added in the future. But generally in cases like this I'd lean towards a discriminated union pattern to distinguish between the different options. Example here in case you're not familiar.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, great idea, Jip! I like them too but somehow I didn't think of using them here. Another upside is that it makes the request type more explicit for the caller.
@@ -119,7 +119,6 @@ const fallBackProvider = (chainId: number) => { | |||
}; | |||
|
|||
/* Returns a PublicClient instance for the configured network. */ | |||
// @ts-expect-error viem typings |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed in the commit messages that this is supposed to not throw an error. It might have been that it was only doing that in Intellij editors. It might also be that this got fixed while upgrading typescript. The original message was about the nested types being too many levels deep, iirc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed it and my editor is fine and the build is working as expected. If you're still using Intellij, can you check if this is okay on your end?
Without this patch the user controller only allows updating the user data directly via EOA. Calling it with a multisig like the Safe would fail as the smart wallet isn't able to create a signature. In this instance the API needs to track signature requests, so that the user data can be written once the signature threshold is met. The need to track these signature requests arises from the fact that you cannot delete signatures from the Safe once they were "used". They will stick around forever and it is up to us to track which signature corresponds to which change, if it is discarded or implemented, etc. The patch introduces a new folder: src/lib/users. The reason is that the project is currently structured by implementation details, so I wanted to keep the user controller in with the rest of the controllers but have a place to store this business logic.
6e50961
to
848f8ca
Compare
Without this patch we can only write signature requests, but not fetch them. This patch implements the required resolvers and also exposes any signature requests that are registered for a particular user.
Without this patch the user has no way of canceling signature requests. This patch introduces a status field. It has 3 possible values: PENDING, CANCELED and EXECUTED. The controller allows canceling the signature request only when it's in status PENDING and the caller authenticates as one of the owners of the Safe.
Without this patch we're missing a way for the API to execute the signature requests and commit the data to record if the signature threshold is met. This patch adds the request POST /signature-requests/process which reads all **pending** signature requests from the data DB, checks if the confirmations meet the signature threshold and if so writes the attached username and avatar changes to the users table. To stay within the rate limit of 5 requests per second required by the Safe API I implemented a rate limited queue and worker that process them concurrently. The request doesn't require authentication, so it could be invoked by a CRON job, as well as the user when they log in or specifically request an update.
This directive didn't have any effect.
In case somebody is wondering why we have duplicate type definitions in our code base.
848f8ca
to
ba00037
Compare
@Jipperism I addressed your feedback. Feel free to leave more comments or close the conversations above 👌 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Congrats on the first PR!
As we discussed, we'll live with two patterns for now and will align during a sync meeting.
Most comments I've made are on logging and type inference/forcing which can be mitigated with helper methods like getAddress
.
message_hash?: StringSearchOptions; | ||
|
||
@Field(() => StringSearchOptions, { nullable: true }) | ||
created_at?: StringSearchOptions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My preference would be a single format for datetime. We basically stay as close to the on-chain data as possible, which would make this a timestamp in second. The datatime used in the other tables also shouldn't be there. It's terrible DX to need to figure out which timestamp you're getting.
request.chain_id, | ||
); | ||
default: | ||
return new NoopCommand(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My assumption here would be that a request with an unknown purpose is an error. Is there a reason to not juss throw here?
safeMessage.message.message, | ||
); | ||
if (!message.success) { | ||
console.log("Unexpected message format", message.error); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
console.error? also for Sentry
const safeMessage = await this.safeApiKit.getMessage(this.messageHash); | ||
|
||
if (!isTypedMessage(safeMessage.message)) { | ||
throw new Error("Unexpected message type: not EIP712TypedData"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there's no log here, but there is one a few lines lower. How will the error be caught? Will it be logged there?
|
||
const verifier = new UserUpsertSignatureVerifier( | ||
Number(signatureRequest.chain_id), | ||
this.safeAddress as `0x${string}`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can use getAddress
from viem
here to verify and not force the type
}; | ||
} | ||
console.log("error", error); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
console.error, if we need to log the error
}, | ||
}; | ||
|
||
const response = await this.supabaseDataService.getSignatureRequests(args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As this inherits the BaseResolver, I think the pattern should match the fetching pattern as you see in the hypercertResolver
. In other works, the command would be return await this.getSignatureRequests(args)
@Field(() => Int, { | ||
description: "The chain ID of the signature request", | ||
}) | ||
chain_id?: number; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
chain_id on chain is uint256, which we should match. This implies bigint | string | number, like in the hypercertTypedefs
@Field({ | ||
description: "When the signature request was created", | ||
}) | ||
created_at?: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See earlier comment on harmonising the datatypes, which means we have a seconds timestamp.
throw new Error(`Unsupported chain ID: ${chainId}`); | ||
} | ||
this.chainId = chainId; | ||
this.safeAddress = safeAddress; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
verify if address is valid address using getAddress
or isAddress
. getAddress
returns with the appropriate casing for consistency
This PR adds support for using a Safe to make profile updates (display name and avatar image).
When the user sends a
POST
request to/v1/users/<safe-address>
with the correct payload for Safe signature flows the user controller will write a signature request to thesignature_requests
table.The signers now need to asynchronously complete the signatures in the Safe app. When the threshold is met, a
POST
to/v1/signature-requests/process
will now queue up all pending signature requests and verify the signatures behind the scenes. The request is non-blocking as it might take a long time to complete.Each signature request will now compute the message hash for the data the user initially submitted, get the signatures for the specific message from the Safe API and validate them on-chain.
executed
and the requested profile change will be written to the database.pending
and retried when the endpoint is called again.This PR also exposes signature requests via GraphQL and attaches all profile change signature requests to a user entity.
Example query:
I couldn't write tests for
SignatureRequestProcessor
due totype-graphql
errors that pop up on tests. We need to fix those first and then I can ship tests for this and the controller classes as a fast-follow.