Skip to content

Commit ffe2b04

Browse files
bxpanaRomsters
andauthored
fix: authorize front-end RPC calls with the Prividium session token (#656)
# What ❔ Attaches the Prividium session token to the front-end's shared `JsonRpcProvider`, so every RPC call the app makes is authorized as the logged in user. - `useContext.getL2Provider` builds the provider from a `FetchRequest` carrying `Authorization: Bearer <session token>` when `currentNetwork.prividium` is set. Public networks keep passing the bare URL, unchanged. - The provider cache now also resets on user change, not just network change, so logging in or out rebuilds it with the right token. - `useTokenOverview` sends the user's address as `from`, which the Prividium RPC also requires. ## Why ❔ Max Total Supply is empty for every token on Prividium chains, reported by Memento (PRIV-185). The provider was constructed with a bare `rpcUrl`, so calls reached the permissions API anonymous. `checkContractAccess` has no anonymous branch, and `eth_call` is separately rejected when it carries no `from`, so the call failed twice over before any permission rule was evaluated. No admin-side permission change could have fixed it. Doing this on the shared provider rather than per call fixes it generically, since the explorer makes other front-end RPC reads that hit the same wall. With this in place an admin controls visibility from the admin panel by adding `totalSupply()` to the token's contract or template function permissions and picking who can call it (`All Users`, or a specific role). ### Note on the first commit The first commit proxied the read through a dedicated explorer API endpoint. Per review that was too narrow, so it is fully reverted in the second commit and the branch diff against `main` touches only the four app files. The `prividium_tokenSupplyDisclosure` route was also considered and skipped: `DISCLOSURE_METHODS_ENABLED` is off in every environment, and it runs a trace plus storage proofs per call to produce a value we would use unverified. ## Checklist - [x] PR title corresponds to the body of PR (we generate changelog entries from PRs). - [x] Tests for the changes have been added / updated. - [x] Documentation comments have been added / updated. --------- Co-authored-by: romsters <petriv.roma@gmail.com>
1 parent 74f01aa commit ffe2b04

6 files changed

Lines changed: 262 additions & 3 deletions

File tree

packages/api/src/prividium.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
33
import { AuthMiddleware } from "./middlewares/auth.middleware";
44
import { AuthModule } from "./auth/auth.module";
55
import { AuthController } from "./auth/auth.controller";
6+
import { RpcModule } from "./rpc/rpc.module";
67
import { NoCacheMiddleware } from "./middlewares/no-cache.middleware";
78
import { AddUserRolesPipe } from "./api/pipes/addUserRoles.pipe";
89
import cookieSession from "cookie-session";
@@ -75,4 +76,4 @@ export function applyPrividiumMiddlewares(consumer: MiddlewareConsumer) {
7576
consumer.apply(AuthMiddleware).forRoutes("*");
7677
}
7778

78-
export const PRIVIDIUM_MODULES = [AuthModule];
79+
export const PRIVIDIUM_MODULES = [AuthModule, RpcModule];
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { mock } from "jest-mock-extended";
2+
import { ConfigService } from "@nestjs/config";
3+
import { UnauthorizedException } from "@nestjs/common";
4+
import { RpcController } from "./rpc.controller";
5+
import { PrividiumApiError } from "../errors/prividiumApiError";
6+
7+
describe("RpcController", () => {
8+
let controller: RpcController;
9+
let configServiceMock: ConfigService;
10+
let fetchSpy: jest.SpyInstance;
11+
12+
const configServiceValues = {
13+
"prividium.permissionsApiUrl": "https://permissions-api.example.com",
14+
};
15+
const user = { address: "0x01", wallets: ["0x01"], token: "token1" };
16+
const body = { jsonrpc: "2.0", id: 1, method: "eth_call", params: [] };
17+
18+
beforeEach(() => {
19+
configServiceMock = mock<ConfigService>({
20+
get: jest.fn().mockImplementation((key: string) => configServiceValues[key]),
21+
});
22+
controller = new RpcController(configServiceMock);
23+
fetchSpy = jest.spyOn(global, "fetch");
24+
});
25+
26+
afterEach(() => {
27+
fetchSpy.mockRestore();
28+
});
29+
30+
it("forwards the request with the session token and returns the response", async () => {
31+
fetchSpy.mockResolvedValueOnce({
32+
status: 200,
33+
ok: true,
34+
json: () => Promise.resolve({ jsonrpc: "2.0", id: 1, result: "0x1" }),
35+
});
36+
37+
expect(await controller.proxy(body, user)).toEqual({ jsonrpc: "2.0", id: 1, result: "0x1" });
38+
expect(fetchSpy).toBeCalledWith(new URL("https://permissions-api.example.com/rpc"), {
39+
method: "POST",
40+
headers: {
41+
"Content-Type": "application/json",
42+
Authorization: `Bearer ${user.token}`,
43+
},
44+
body: JSON.stringify(body),
45+
});
46+
});
47+
48+
it("rejects requests without a session", async () => {
49+
await expect(controller.proxy(body, null)).rejects.toThrowError(UnauthorizedException);
50+
expect(fetchSpy).not.toBeCalled();
51+
});
52+
53+
it("throws when the permissions API rejects the token", async () => {
54+
fetchSpy.mockResolvedValueOnce({ status: 401, ok: false, json: () => Promise.resolve({}) });
55+
56+
await expect(controller.proxy(body, user)).rejects.toThrowError(PrividiumApiError);
57+
});
58+
59+
it("throws a bad gateway when the permissions API fails", async () => {
60+
fetchSpy.mockResolvedValueOnce({ status: 500, ok: false, json: () => Promise.resolve({}) });
61+
62+
await expect(controller.proxy(body, user)).rejects.toThrowError(expect.objectContaining({ status: 502 }) as Error);
63+
});
64+
65+
it("throws a bad gateway when the response is not JSON", async () => {
66+
fetchSpy.mockResolvedValueOnce({
67+
status: 200,
68+
ok: true,
69+
json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")),
70+
});
71+
72+
await expect(controller.proxy(body, user)).rejects.toThrowError(expect.objectContaining({ status: 502 }) as Error);
73+
});
74+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Body, Controller, Header, Post, UnauthorizedException } from "@nestjs/common";
2+
import { ApiExcludeController, ApiOkResponse, ApiTags } from "@nestjs/swagger";
3+
import { ConfigService } from "@nestjs/config";
4+
import { swagger } from "../config/featureFlags";
5+
import { PrividiumApiError } from "../errors/prividiumApiError";
6+
import { User, UserParam } from "../user/user.decorator";
7+
8+
const entityName = "rpc";
9+
10+
@ApiTags("RPC BFF")
11+
@ApiExcludeController(!swagger.bffEnabled)
12+
@Controller(entityName)
13+
export class RpcController {
14+
constructor(private readonly configService: ConfigService) {}
15+
16+
// Prividium authorizes every RPC call against the caller and only the session holds the
17+
// user's permissions API token, so the app's RPC calls are made on its behalf here.
18+
@Post("")
19+
@Header("Content-Type", "application/json")
20+
@ApiOkResponse({ description: "JSON-RPC response returned by the permissions API" })
21+
public async proxy(@Body() body: unknown, @User() user: UserParam): Promise<unknown> {
22+
if (!user) {
23+
throw new UnauthorizedException({ message: "Unauthorized request" });
24+
}
25+
26+
const response = await fetch(new URL("/rpc", this.configService.get("prividium.permissionsApiUrl")), {
27+
method: "POST",
28+
headers: {
29+
"Content-Type": "application/json",
30+
Authorization: `Bearer ${user.token}`,
31+
},
32+
body: JSON.stringify(body),
33+
});
34+
35+
if (response.status === 401 || response.status === 403) {
36+
throw new PrividiumApiError("Invalid or expired token", 401);
37+
}
38+
39+
// A JSON-RPC error is carried in a 2xx body, so any other status is a transport failure.
40+
// Without this an upstream 5xx is returned to the app as a 200 with a body ethers cannot parse.
41+
if (!response.ok) {
42+
throw new PrividiumApiError("Invalid response from permissions API", 502);
43+
}
44+
45+
try {
46+
return await response.json();
47+
} catch {
48+
throw new PrividiumApiError("Invalid response from permissions API", 502);
49+
}
50+
}
51+
}

packages/api/src/rpc/rpc.module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Module } from "@nestjs/common";
2+
import { RpcController } from "./rpc.controller";
3+
4+
@Module({
5+
controllers: [RpcController],
6+
})
7+
export class RpcModule {}

packages/app/src/composables/useContext.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { computed, type ComputedRef, type Ref, ref, watch } from "vue";
22

33
import { useStorage } from "@vueuse/core";
4-
import { JsonRpcProvider } from "ethers";
4+
import { FetchRequest, JsonRpcProvider, makeError } from "ethers";
55

66
import useEnvironmentConfig from "./useEnvironmentConfig";
77
import { DEFAULT_NETWORK } from "./useRuntimeConfig";
@@ -31,6 +31,59 @@ export type Context = {
3131
isGatewaySettlementChain: (chainId: number | null) => boolean;
3232
};
3333

34+
// Prividium authorizes every RPC call against the caller, and only the explorer API session
35+
// holds the user's token, so RPC calls go through the API instead of straight to the RPC.
36+
// The session cookie is set on the API origin and ethers does not send cross-origin
37+
// credentials, so the request is made with a fetch that includes them.
38+
function getRpcRequest(network: NetworkConfig) {
39+
if (!network.prividium) {
40+
return network.rpcUrl;
41+
}
42+
43+
const request = new FetchRequest(`${network.apiUrl}/rpc`);
44+
// Overriding getUrlFunc replaces ethers' own fetch, which arms `req.timeout` and forwards
45+
// cancellation, so both are reproduced here to keep requests bounded and abortable.
46+
request.getUrlFunc = async (req, signal) => {
47+
const controller = new AbortController();
48+
let abortError: Error | null = null;
49+
const timer = setTimeout(() => {
50+
abortError = makeError("request timeout", "TIMEOUT");
51+
controller.abort();
52+
}, req.timeout);
53+
signal?.addListener(() => {
54+
abortError = makeError("request cancelled", "CANCELLED");
55+
controller.abort();
56+
});
57+
58+
let response: Response;
59+
try {
60+
response = await fetch(req.url, {
61+
method: req.method,
62+
headers: req.headers,
63+
body: req.body,
64+
credentials: "include",
65+
signal: controller.signal,
66+
});
67+
} catch (error) {
68+
throw abortError ?? error;
69+
} finally {
70+
clearTimeout(timer);
71+
}
72+
73+
const headers: Record<string, string> = {};
74+
response.headers.forEach((value, key) => {
75+
headers[key.toLowerCase()] = value;
76+
});
77+
return {
78+
statusCode: response.status,
79+
statusMessage: response.statusText,
80+
headers,
81+
body: new Uint8Array(await response.arrayBuffer()),
82+
};
83+
};
84+
return request;
85+
}
86+
3487
let l2Provider: JsonRpcProvider | null;
3588
export default (): Context => {
3689
const environmentConfig = useEnvironmentConfig();
@@ -78,7 +131,7 @@ export default (): Context => {
78131

79132
function getL2Provider() {
80133
if (!l2Provider) {
81-
l2Provider = new JsonRpcProvider(currentNetwork.value.rpcUrl, currentNetwork.value.l2ChainId, {
134+
l2Provider = new JsonRpcProvider(getRpcRequest(currentNetwork.value), currentNetwork.value.l2ChainId, {
82135
staticNetwork: true,
83136
});
84137
}

packages/app/tests/composables/useContext.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,77 @@ describe("useContext:", () => {
8989
});
9090
});
9191
});
92+
93+
describe("getL2Provider:", () => {
94+
const PUBLIC_NETWORK = { ...TESTNET_NETWORK, name: "public", rpcUrl: "https://rpc.example.com" };
95+
const PRIVIDIUM_NETWORK = {
96+
...TESTNET_NETWORK,
97+
name: "prividium",
98+
prividium: true,
99+
apiUrl: "https://api.example.com",
100+
rpcUrl: "https://rpc.example.com",
101+
};
102+
103+
const buildContext = (network: typeof TESTNET_NETWORK) => {
104+
const mockStorage = vi.spyOn(Storage.prototype, "getItem").mockReturnValue(network.name);
105+
const mockEnvironmentConfig = vi.spyOn(useEnvironmentConfig, "default").mockReturnValue({
106+
networks: computed(() => [network]),
107+
baseTokenAddress: computed(() => "0x000000000000000000000000000000000000800A"),
108+
});
109+
const context = useContext.default();
110+
context.identifyNetwork();
111+
return { context, restore: () => [mockStorage, mockEnvironmentConfig].forEach((m) => m.mockRestore()) };
112+
};
113+
114+
it("connects straight to the RPC on a public network", () => {
115+
const { context, restore } = buildContext(PUBLIC_NETWORK);
116+
117+
expect(context.getL2Provider()._getConnection().url).toBe("https://rpc.example.com");
118+
restore();
119+
});
120+
121+
it("connects through the explorer API on a Prividium network", () => {
122+
const { context, restore } = buildContext(PRIVIDIUM_NETWORK);
123+
124+
expect(context.getL2Provider()._getConnection().url).toBe("https://api.example.com/rpc");
125+
restore();
126+
});
127+
128+
it("sends session credentials and an abort signal on a Prividium network", async () => {
129+
const { context, restore } = buildContext(PRIVIDIUM_NETWORK);
130+
const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue({
131+
status: 200,
132+
statusText: "OK",
133+
headers: new Headers(),
134+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
135+
} as unknown as Response);
136+
137+
const request = context.getL2Provider()._getConnection();
138+
await request.getUrlFunc(request);
139+
140+
expect(fetchMock).toBeCalledWith(
141+
"https://api.example.com/rpc",
142+
expect.objectContaining({ credentials: "include", signal: expect.any(AbortSignal) })
143+
);
144+
fetchMock.mockRestore();
145+
restore();
146+
});
147+
148+
it("aborts a Prividium request that exceeds the request timeout", async () => {
149+
const { context, restore } = buildContext(PRIVIDIUM_NETWORK);
150+
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(
151+
(_url, init) =>
152+
new Promise((_resolve, reject) => {
153+
(init as RequestInit).signal?.addEventListener("abort", () => reject(new Error("aborted")));
154+
})
155+
);
156+
157+
const request = context.getL2Provider()._getConnection();
158+
request.timeout = 1;
159+
160+
await expect(request.getUrlFunc(request)).rejects.toThrowError(/timeout/);
161+
fetchMock.mockRestore();
162+
restore();
163+
});
164+
});
92165
});

0 commit comments

Comments
 (0)