Skip to content

Commit 1a7554f

Browse files
authored
Merge pull request #35 from BeanstalkFarms/codex/rpc-cost-optimization-port
[codex] Optimize API RPC usage
2 parents c0ba766 + a302b2f commit 1a7554f

11 files changed

Lines changed: 552 additions & 44 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# RPC Cost Optimization Port Plan
2+
3+
Date: 2026-05-09
4+
5+
This captures the initial Pinto-to-Beanstalk port plan before code changes. It is intentionally redacted: exposed key material is described by source and protection behavior, not copied.
6+
7+
## Pinto Reference Changes
8+
9+
- Pinto frontend:
10+
- `Reduce conservative RPC waste`
11+
- `Add app version refresh guard`
12+
- Pinto API:
13+
- `Optimize RPC usage`
14+
- Pinto bots:
15+
- `Optimize event RPC polling`
16+
- `Add shared event dispatcher`
17+
- `Fix dispatcher routing regressions`
18+
- `Add Phase 3A RPC cleanup`
19+
20+
## Live Key Exposure Audit
21+
22+
- `https://pinto.money/` currently did not expose full Alchemy RPC URLs in the initial deployed JS/CSS bundle. The source still builds RPC URLs from `VITE_ALCHEMY_API_KEY`, so any configured Vite key is public by design.
23+
- `https://bean.money/` and `https://www.bean.money/` did not expose Alchemy RPC URLs in the initial deployed JS/CSS bundles checked.
24+
- `https://app.bean.money/` exposes Alchemy RPC URLs in the deployed app bundle. The Beanstalk production ETH/Arbitrum key matched the local UI production key and rejected requests with no Origin or a foreign Origin, while accepting the app Origin.
25+
- The Beanstalk app bundle also contains provider-library default/community Alchemy endpoints. One Optimism endpoint accepted a foreign Origin, but it did not match the Beanstalk UI production key.
26+
- Local Beanstalk UI source contains a hardcoded Alchemy NFT API URL in `projects/ui/src/pages/nft.tsx`. It was not seen in the initial live app bundle scan, but it should still be treated as exposed source key material if the route or code is shipped elsewhere.
27+
- Local Pinto UI source contains Alchemy URL construction in `src/utils/wagmi/chains.ts` and `src/pages/DevToolsInstall.tsx`, plus literal Alchemy URL examples/comments in the dev tools page. These should be removed or rotated if they point at live apps.
28+
29+
## Security Posture
30+
31+
- All `VITE_*`/client-side RPC keys should be treated as public identifiers, not secrets.
32+
- Frontend Alchemy keys are acceptable only if separately scoped for browser use, origin/domain restricted, quota limited, and monitored.
33+
- Backend/API/bot keys should use separate Alchemy apps with no browser origin allowlist dependency and should never be bundled into frontends.
34+
- Rotate any key that appears literally in source, comments, docs, old bundles, or test fixtures if it has real permissions or billable quota.
35+
36+
## Port Order
37+
38+
1. Beanstalk API:
39+
- Port provider request batching and optional RPC request logging.
40+
- Add historical RPC call caching for explicit `blockTag` reads.
41+
- Add Multicall3 helpers where Beanstalk endpoints perform repeated per-token reads.
42+
- Avoid Pinto's Base-only timestamp lookup shortcut unless Beanstalk chain behavior is handled explicitly.
43+
2. Beanstalk bots:
44+
- Port HTTP RPC request counting.
45+
- Add a shared event dispatcher for Well, Beanstalk, and Market monitors.
46+
- De-duplicate contract/event filters and reuse receipts instead of fetching the same transaction receipts repeatedly.
47+
- Keep Beanstalk-only monitors such as Barn Raise and Contracts Migrated separate until routing is verified.
48+
3. Beanstalk frontend:
49+
- Port the app version refresh guard.
50+
- Audit polling intervals and heavy reads against Pinto's lower-cost query defaults.
51+
- Remove literal Alchemy URL/key examples from UI source where practical.
52+
53+
## Verification Targets
54+
55+
- API: targeted Jest tests around provider creation, cache behavior, and multicall-compatible service paths.
56+
- Bots: targeted unit/integration tests for event source construction, dispatcher routing, and receipt reuse.
57+
- Frontend: build/typecheck if the existing Beanstalk UI dependency state allows it; otherwise, static verification and minimal focused checks.
58+

src/datasources/alchemy.js

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,111 @@
11
const { Alchemy } = require('alchemy-sdk');
22
const EnvUtil = require('../utils/env');
3+
const { ethers } = require('ethers');
4+
const Log = require('../utils/logging');
5+
6+
const RPC_BATCH_OPTIONS = {
7+
batchMaxCount: Number(process.env.RPC_BATCH_MAX_COUNT ?? 10),
8+
batchStallTime: Number(process.env.RPC_BATCH_STALL_TIME_MS ?? 10),
9+
batchMaxSize: Number(process.env.RPC_BATCH_MAX_SIZE ?? 1024 * 1024)
10+
};
11+
const ETHERS_NETWORKS = {
12+
eth: { name: 'mainnet', chainId: 1 },
13+
arb: { name: 'arbitrum', chainId: 42161 }
14+
};
315

416
class AlchemyUtil {
17+
// Contains alchemy object
18+
static _alchemies = {};
519
// Contains a provider by chain
620
static _providers = {};
721
// Access to the underlying promise whos execution populates _providers.
822
// Allows flexibility in awaiting when necessary (i.e. once at application startup)
923
static _providerPromises = {};
24+
static _rpcCounters = {};
1025

1126
static {
1227
for (const chain of EnvUtil.getEnabledChains()) {
13-
const settings = {
14-
apiKey: EnvUtil.getAlchemyKey(),
15-
network: `${chain}-mainnet` // Of type alchemy-sdk.Network
16-
};
17-
const alchemy = new Alchemy(settings);
18-
this._providerPromises[chain] = alchemy.config.getProvider().then((p) => {
19-
this._providers[chain] = p;
20-
});
28+
if (EnvUtil.getCustomRpcUrl(chain)) {
29+
this._providers[chain] = this._makeProvider(chain, EnvUtil.getCustomRpcUrl(chain));
30+
} else {
31+
const settings = {
32+
apiKey: EnvUtil.getAlchemyKey(),
33+
network: `${chain}-mainnet` // Of type alchemy-sdk.Network
34+
};
35+
this._alchemies[chain] = new Alchemy(settings);
36+
this._providers[chain] = this._makeProvider(
37+
chain,
38+
`https://${chain}-mainnet.g.alchemy.com/v2/${EnvUtil.getAlchemyKey()}`
39+
);
40+
}
2141
}
2242
}
2343

44+
static alchemyForChain(chain) {
45+
return AlchemyUtil._alchemies[chain];
46+
}
47+
2448
static providerForChain(chain) {
2549
return AlchemyUtil._providers[chain];
2650
}
2751

52+
static rpcCounts(chain) {
53+
return AlchemyUtil._rpcCounters[chain];
54+
}
55+
2856
// Returns immediately if already resolved (and _providers is populated)
2957
static async ready(chain) {
3058
await AlchemyUtil._providerPromises[chain];
3159
}
60+
61+
static _makeProvider(chain, url) {
62+
const provider = new ethers.JsonRpcProvider(url, ETHERS_NETWORKS[chain], RPC_BATCH_OPTIONS);
63+
// Needed to get the alchemy-sdk Contract constructor to work with an ethers v6 provider.
64+
provider._isProvider = true;
65+
return AlchemyUtil._withRequestCounter(chain, provider);
66+
}
67+
68+
static _withRequestCounter(chain, provider) {
69+
if (process.env.LOG_RPC !== '1' || !provider._send) {
70+
return provider;
71+
}
72+
73+
const counter = {
74+
total: 0,
75+
methods: {}
76+
};
77+
AlchemyUtil._rpcCounters[chain] = counter;
78+
79+
const originalSend = provider._send.bind(provider);
80+
provider._send = async (payload) => {
81+
const requests = Array.isArray(payload) ? payload : [payload];
82+
for (const request of requests) {
83+
counter.total++;
84+
counter.methods[request.method] = (counter.methods[request.method] ?? 0) + 1;
85+
}
86+
return await originalSend(payload);
87+
};
88+
89+
let didLog = false;
90+
const logCounts = () => {
91+
if (didLog) {
92+
return;
93+
}
94+
didLog = true;
95+
Log.info(`RPC request counts for ${chain}`, JSON.stringify(counter));
96+
};
97+
process.once('beforeExit', logCounts);
98+
process.once('exit', logCounts);
99+
process.once('SIGINT', () => {
100+
logCounts();
101+
process.exit(0);
102+
});
103+
process.once('SIGTERM', () => {
104+
logCounts();
105+
process.exit(0);
106+
});
107+
return provider;
108+
}
32109
}
33110

34111
module.exports = AlchemyUtil;

src/datasources/contracts/contracts.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { Contract: AlchemyContract } = require('alchemy-sdk');
22
const { C } = require('../../constants/runtime-constants');
3+
const RpcCache = require('../rpc-cache');
34
const SuperContract = require('./super-contract');
45
const wellFunctionAbi = require('../../datasources/abi/basin/WellFunction.json');
56

@@ -20,10 +21,11 @@ class Contracts {
2021

2122
static makeContract(address, abi, provider) {
2223
const underlyingContract = new AlchemyContract(address, abi, provider);
23-
return new SuperContract(underlyingContract);
24+
return RpcCache.wrapContract(new SuperContract(underlyingContract), address);
2425
}
2526

2627
static _getDefaultContract(address, c = C()) {
28+
address = address.toLowerCase();
2729
const network = c.CHAIN;
2830
const key = JSON.stringify({ address, network });
2931
if (!Contracts._contracts[key]) {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const { C } = require('../../constants/runtime-constants');
2+
const retryable = require('../../utils/async/retryable');
3+
const SuperContract = require('./super-contract');
4+
const Contracts = require('./contracts');
5+
6+
const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11';
7+
const MULTICALL3_ABI = [
8+
{
9+
inputs: [
10+
{
11+
components: [
12+
{ internalType: 'address', name: 'target', type: 'address' },
13+
{ internalType: 'bool', name: 'allowFailure', type: 'bool' },
14+
{ internalType: 'bytes', name: 'callData', type: 'bytes' }
15+
],
16+
internalType: 'struct Multicall3.Call3[]',
17+
name: 'calls',
18+
type: 'tuple[]'
19+
}
20+
],
21+
name: 'aggregate3',
22+
outputs: [
23+
{
24+
components: [
25+
{ internalType: 'bool', name: 'success', type: 'bool' },
26+
{ internalType: 'bytes', name: 'returnData', type: 'bytes' }
27+
],
28+
internalType: 'struct Multicall3.Result[]',
29+
name: 'returnData',
30+
type: 'tuple[]'
31+
}
32+
],
33+
stateMutability: 'view',
34+
type: 'function'
35+
}
36+
];
37+
38+
class Multicall {
39+
static async aggregate(calls, { blockTag = 'latest', c = C() } = {}) {
40+
if (calls.length === 0) {
41+
return [];
42+
}
43+
44+
const results = new Array(calls.length);
45+
const callsByBlock = calls.reduce((acc, call, index) => {
46+
const callBlock = call.blockTag ?? blockTag;
47+
if (!acc.has(callBlock)) {
48+
acc.set(callBlock, []);
49+
}
50+
acc.get(callBlock).push({ call, index });
51+
return acc;
52+
}, new Map());
53+
54+
await Promise.all(
55+
[...callsByBlock.entries()].map(async ([callBlock, entries]) => {
56+
const multicall = Contracts.makeContract(MULTICALL3_ADDRESS, MULTICALL3_ABI, c.RPC);
57+
const aggregateCalls = entries.map(({ call }) => ({
58+
target: call.contract.address,
59+
allowFailure: call.allowFailure ?? false,
60+
callData: call.contract.interface.encodeFunctionData(call.method, call.args ?? [])
61+
}));
62+
63+
const aggregateResults = await retryable(() =>
64+
multicall.aggregate3({ target: 'SuperContract', skipTransform: true }, aggregateCalls, {
65+
blockTag: callBlock
66+
})
67+
);
68+
69+
for (let i = 0; i < entries.length; ++i) {
70+
const { call, index } = entries[i];
71+
const result = aggregateResults[i];
72+
if (!result.success) {
73+
if (call.allowFailure) {
74+
results[index] = null;
75+
continue;
76+
}
77+
throw new Error(`Multicall failed for ${call.contract.address}.${call.method}`);
78+
}
79+
80+
const fragment = call.contract.interface.getFunction(call.method);
81+
const decoded = call.contract.interface.decodeFunctionResult(fragment, result.returnData);
82+
results[index] = SuperContract._transformAll(fragment.outputs.length === 1 ? decoded[0] : decoded);
83+
}
84+
})
85+
);
86+
87+
return results;
88+
}
89+
}
90+
91+
module.exports = Multicall;

src/datasources/contracts/super-contract.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,17 @@ class SuperContract {
1717
}
1818

1919
return async (...args) => {
20-
const rawResult = await retryable(() => contract[property](...args));
20+
const { superOptions, contractArgs } = SuperContract._identifySuperArgs(args);
21+
22+
const retryableOptions = {};
23+
if (superOptions?.skipRetry) {
24+
retryableOptions.earlyTerminate = superOptions.skipRetry;
25+
}
26+
const rawResult = await retryable(() => contract[property](...contractArgs), retryableOptions);
27+
28+
if (superOptions?.skipTransform) {
29+
return rawResult;
30+
}
2131
return SuperContract._transformAll(rawResult);
2232
};
2333
}
@@ -26,6 +36,15 @@ class SuperContract {
2636
return new Proxy(this, proxyHandler);
2737
}
2838

39+
static _identifySuperArgs(args) {
40+
const superArgsIndex = args.findIndex((a) => a?.target === 'SuperContract');
41+
if (superArgsIndex !== -1) {
42+
const superOptions = args.splice(superArgsIndex, 1)[0];
43+
return { superOptions, contractArgs: args };
44+
}
45+
return { contractArgs: args };
46+
}
47+
2948
// Transforms everything in this result to be a BigInt.
3049
// Handles arrays/tuples (with named fields), and single values
3150
static _transformAll(rawResult) {

0 commit comments

Comments
 (0)