Skip to content

Commit 0206314

Browse files
committed
feat: add signature request queue processing
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.
1 parent 350fbee commit 0206314

File tree

17 files changed

+706
-105
lines changed

17 files changed

+706
-105
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@ipld/car": "^5.2.5",
3333
"@openzeppelin/merkle-tree": "^1.0.5",
3434
"@safe-global/api-kit": "^2.5.4",
35+
"@safe-global/protocol-kit": "^5.0.4",
3536
"@sentry/integrations": "^7.114.0",
3637
"@sentry/node": "^8.2.1",
3738
"@sentry/profiling-node": "^8.2.1",

pnpm-lock.yaml

Lines changed: 66 additions & 103 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__generated__/routes/routes.ts

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__generated__/swagger.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/CommandFactory.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Database } from "../types/supabaseData.js";
2+
import { ISafeApiCommand } from "../types/safe-signatures.js";
3+
4+
import { UserUpsertCommand } from "./UserUpsertCommand.js";
5+
import { SafeApiCommand } from "./SafeApiCommand.js";
6+
7+
type SignatureRequest =
8+
Database["public"]["Tables"]["signature_requests"]["Row"];
9+
10+
export function getCommand(request: SignatureRequest): ISafeApiCommand {
11+
switch (request.purpose) {
12+
case "update_user_data":
13+
return new UserUpsertCommand(
14+
request.safe_address,
15+
request.message_hash,
16+
request.chain_id,
17+
);
18+
default:
19+
console.warn("Unrecognized purpose:", request.purpose);
20+
return new NoopCommand();
21+
}
22+
}
23+
24+
class NoopCommand extends SafeApiCommand implements ISafeApiCommand {
25+
constructor() {
26+
super("", "", 0);
27+
}
28+
29+
async execute(): Promise<void> {}
30+
}

src/commands/SafeApiCommand.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import SafeApiKit from "@safe-global/api-kit";
2+
3+
import { SupabaseDataService } from "../services/SupabaseDataService.js";
4+
import { ISafeApiCommand } from "../types/safe-signatures.js";
5+
6+
export abstract class SafeApiCommand implements ISafeApiCommand {
7+
protected readonly safeAddress: string;
8+
protected readonly messageHash: string;
9+
protected readonly chainId: number;
10+
protected readonly dataService: SupabaseDataService;
11+
protected readonly safeApiKit: SafeApiKit.default;
12+
13+
constructor(safeAddress: string, messageHash: string, chainId: number) {
14+
this.safeAddress = safeAddress;
15+
this.messageHash = messageHash;
16+
this.chainId = chainId;
17+
this.dataService = new SupabaseDataService();
18+
this.safeApiKit = new SafeApiKit.default({ chainId: BigInt(chainId) });
19+
}
20+
21+
abstract execute(): Promise<void>;
22+
23+
getId(): string {
24+
return `${this.chainId}-${this.safeAddress}-${this.messageHash}`;
25+
}
26+
}

src/commands/UserUpsertCommand.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { getAddress } from "viem";
2+
3+
import {
4+
MultisigUserUpdateMessage,
5+
USER_UPDATE_MESSAGE_SCHEMA,
6+
} from "../lib/users/schemas.js";
7+
import { isTypedMessage } from "../utils/signatures.js";
8+
import UserUpsertSignatureVerifier from "../lib/safe-signature-verification/UserUpsertSignatureVerifier.js";
9+
import { Database } from "../types/supabaseData.js";
10+
11+
import { SafeApiCommand } from "./SafeApiCommand.js";
12+
13+
type SignatureRequest =
14+
Database["public"]["Tables"]["signature_requests"]["Row"];
15+
16+
export class UserUpsertCommand extends SafeApiCommand {
17+
async execute(): Promise<void> {
18+
const signatureRequest = await this.dataService.getSignatureRequest(
19+
this.safeAddress,
20+
this.messageHash,
21+
);
22+
23+
if (!signatureRequest || signatureRequest.status !== "pending") {
24+
return;
25+
}
26+
27+
const safeMessage = await this.safeApiKit.getMessage(this.messageHash);
28+
29+
if (!isTypedMessage(safeMessage.message)) {
30+
throw new Error("Unexpected message type: not EIP712TypedData");
31+
}
32+
33+
const message = USER_UPDATE_MESSAGE_SCHEMA.safeParse(
34+
safeMessage.message.message,
35+
);
36+
if (!message.success) {
37+
console.error("Unexpected message format", message.error);
38+
throw new Error("Unexpected message format");
39+
}
40+
41+
const verifier = new UserUpsertSignatureVerifier(
42+
Number(signatureRequest.chain_id),
43+
getAddress(this.safeAddress),
44+
message.data,
45+
);
46+
47+
if (!(await verifier.verify(safeMessage.preparedSignature))) {
48+
console.error(`Signature verification failed: ${this.getId()}`);
49+
return;
50+
}
51+
52+
await this.updateDatabase(signatureRequest, message.data);
53+
console.log(`Signature request executed: ${this.getId()}`);
54+
}
55+
56+
async updateDatabase(
57+
signatureRequest: Exclude<SignatureRequest, undefined>,
58+
message: MultisigUserUpdateMessage,
59+
): Promise<void> {
60+
const users = await this.dataService.upsertUsers([
61+
{
62+
address: this.safeAddress,
63+
chain_id: signatureRequest.chain_id,
64+
display_name: message.user.displayName,
65+
avatar: message.user.avatar,
66+
},
67+
]);
68+
if (!users.length) {
69+
throw new Error("Error adding or updating user");
70+
}
71+
await this.dataService.updateSignatureRequestStatus(
72+
this.safeAddress,
73+
this.messageHash,
74+
"executed",
75+
);
76+
}
77+
}

src/controllers/SignatureRequestController.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111

1212
import { SupabaseDataService } from "../services/SupabaseDataService.js";
1313
import { verifyAuthSignedData } from "../utils/verifyAuthSignedData.js";
14+
import { SignatureRequestProcessor } from "../services/SignatureRequestProcessor.js";
1415

1516
interface CancelSignatureRequest {
1617
signature: string;
@@ -75,6 +76,25 @@ export class SignatureRequestController extends Controller {
7576
}
7677
}
7778

79+
@Post("process")
80+
@SuccessResponse(200, "Signature requests processing started")
81+
public async processSignatureRequests(): Promise<{
82+
success: boolean;
83+
message: string;
84+
}> {
85+
try {
86+
const processor = SignatureRequestProcessor.getInstance();
87+
console.log("Processing pending requests");
88+
await processor.processPendingRequests();
89+
return this.successResponse("Signature requests processing started");
90+
} catch (error) {
91+
const message = error instanceof Error ? error.message : "Unknown error";
92+
return this.errorResponse(
93+
`Failed to process signature requests: ${message}`,
94+
);
95+
}
96+
}
97+
7898
async isValidSignature(
7999
safeAddress: string,
80100
messageHash: string,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { getAddress, hashTypedData, type HashTypedDataParameters } from "viem";
2+
import Safe from "@safe-global/protocol-kit";
3+
4+
import { getRpcUrl } from "../../utils/getRpcUrl.js";
5+
6+
export default abstract class SafeSignatureVerifier {
7+
protected chainId: number;
8+
protected safeAddress: `0x${string}`;
9+
protected rpcUrl: string;
10+
11+
constructor(chainId: number, safeAddress: `0x${string}`) {
12+
const rpcUrl = getRpcUrl(chainId);
13+
14+
if (!rpcUrl) {
15+
throw new Error(`Unsupported chain ID: ${chainId}`);
16+
}
17+
this.chainId = chainId;
18+
this.safeAddress = getAddress(safeAddress);
19+
this.rpcUrl = rpcUrl;
20+
}
21+
22+
hashTypedData() {
23+
const parameters = this.buildTypedData();
24+
25+
return hashTypedData({
26+
domain: {
27+
name: "Hypercerts",
28+
version: "1",
29+
chainId: this.chainId,
30+
verifyingContract: this.safeAddress,
31+
},
32+
...parameters,
33+
});
34+
}
35+
36+
abstract buildTypedData(): Omit<HashTypedDataParameters, "domain">;
37+
38+
async verify(signature: string): Promise<boolean> {
39+
const safe = await Safe.default.init({
40+
provider: this.rpcUrl,
41+
safeAddress: this.safeAddress,
42+
});
43+
44+
const protocolKit = await safe.connect({});
45+
return protocolKit.isValidSignature(this.hashTypedData(), signature);
46+
}
47+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { MultisigUserUpdateMessage } from "../users/schemas.js";
2+
3+
import SafeSignatureVerifier from "./SafeSignatureVerifier.js";
4+
5+
export default class UserUpsertSignatureVerifier extends SafeSignatureVerifier {
6+
private message: MultisigUserUpdateMessage;
7+
8+
constructor(
9+
chainId: number,
10+
safeAddress: `0x${string}`,
11+
message: MultisigUserUpdateMessage,
12+
) {
13+
super(chainId, safeAddress);
14+
this.message = message;
15+
}
16+
17+
buildTypedData() {
18+
return {
19+
types: {
20+
Metadata: [{ name: "timestamp", type: "uint256" }],
21+
User: [
22+
{ name: "displayName", type: "string" },
23+
{ name: "avatar", type: "string" },
24+
],
25+
UserUpdateRequest: [
26+
{ name: "metadata", type: "Metadata" },
27+
{ name: "user", type: "User" },
28+
],
29+
},
30+
primaryType: "UserUpdateRequest",
31+
message: this.message,
32+
};
33+
}
34+
}

0 commit comments

Comments
 (0)