feat(rln): price calculator for rate limits (#2480)

* chore: add ABI for PriceCalculator

* chore: rename LINEA_CONTRACT to RLN_CONTRACT

* chore: add price calculator & test

* fix: import

* chore: convert e2e test to unit

* fix: test
This commit is contained in:
Danish Arora 2025-07-16 15:16:13 +05:30 committed by GitHub
parent 35acdf8fa5
commit 7f7f772d93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 220 additions and 23 deletions

View File

@ -0,0 +1,93 @@
export const PRICE_CALCULATOR_ABI = [
{
inputs: [
{ internalType: "address", name: "_token", type: "address" },
{
internalType: "uint256",
name: "_pricePerMessagePerEpoch",
type: "uint256"
}
],
stateMutability: "nonpayable",
type: "constructor"
},
{ inputs: [], name: "OnlyTokensAllowed", type: "error" },
{
anonymous: false,
inputs: [
{
indexed: true,
internalType: "address",
name: "previousOwner",
type: "address"
},
{
indexed: true,
internalType: "address",
name: "newOwner",
type: "address"
}
],
name: "OwnershipTransferred",
type: "event"
},
{
inputs: [{ internalType: "uint32", name: "_rateLimit", type: "uint32" }],
name: "calculate",
outputs: [
{ internalType: "address", name: "", type: "address" },
{ internalType: "uint256", name: "", type: "uint256" }
],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "owner",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "pricePerMessagePerEpoch",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function"
},
{
inputs: [],
name: "renounceOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [
{ internalType: "address", name: "_token", type: "address" },
{
internalType: "uint256",
name: "_pricePerMessagePerEpoch",
type: "uint256"
}
],
name: "setTokenAndPrice",
outputs: [],
stateMutability: "nonpayable",
type: "function"
},
{
inputs: [],
name: "token",
outputs: [{ internalType: "address", name: "", type: "address" }],
stateMutability: "view",
type: "function"
},
{
inputs: [{ internalType: "address", name: "newOwner", type: "address" }],
name: "transferOwnership",
outputs: [],
stateMutability: "nonpayable",
type: "function"
}
];

View File

@ -1,11 +1,18 @@
import { RLN_ABI } from "./abi.js";
import { PRICE_CALCULATOR_ABI } from "./abi/price_calculator.js";
import { RLN_ABI } from "./abi/rln.js";
export const LINEA_CONTRACT = {
export const RLN_CONTRACT = {
chainId: 59141,
address: "0xb9cd878c90e49f797b4431fbf4fb333108cb90e6",
abi: RLN_ABI
};
export const PRICE_CALCULATOR_CONTRACT = {
chainId: 59141,
address: "0xBcfC0660Df69f53ab409F32bb18A3fb625fcE644",
abi: PRICE_CALCULATOR_ABI
};
/**
* Rate limit tiers (messages per epoch)
* Each membership can specify a rate limit within these bounds.

View File

@ -0,0 +1,66 @@
import { expect, use } from "chai";
import chaiAsPromised from "chai-as-promised";
import { ethers } from "ethers";
import sinon from "sinon";
import { RLNBaseContract } from "./rln_base_contract.js";
use(chaiAsPromised);
function createMockRLNBaseContract(provider: any): RLNBaseContract {
const dummy = Object.create(RLNBaseContract.prototype);
dummy.contract = { provider };
return dummy as RLNBaseContract;
}
describe("RLNBaseContract.getPriceForRateLimit (unit)", function () {
let provider: any;
let calculateStub: sinon.SinonStub;
let mockContractFactory: any;
beforeEach(() => {
provider = {};
calculateStub = sinon.stub();
mockContractFactory = function () {
return { calculate: calculateStub };
};
});
afterEach(() => {
sinon.restore();
});
it("returns token and price for valid calculate", async () => {
const fakeToken = "0x1234567890abcdef1234567890abcdef12345678";
const fakePrice = ethers.BigNumber.from(42);
calculateStub.resolves([fakeToken, fakePrice]);
const rlnBase = createMockRLNBaseContract(provider);
const result = await rlnBase.getPriceForRateLimit(20, mockContractFactory);
expect(result.token).to.equal(fakeToken);
expect(result.price).to.not.be.null;
if (result.price) {
expect(result.price.eq(fakePrice)).to.be.true;
}
expect(calculateStub.calledOnceWith(20)).to.be.true;
});
it("throws if calculate throws", async () => {
calculateStub.rejects(new Error("fail"));
const rlnBase = createMockRLNBaseContract(provider);
await expect(
rlnBase.getPriceForRateLimit(20, mockContractFactory)
).to.be.rejectedWith("fail");
expect(calculateStub.calledOnceWith(20)).to.be.true;
});
it("throws if calculate returns malformed data", async () => {
calculateStub.resolves([null, null]);
const rlnBase = createMockRLNBaseContract(provider);
const result = await rlnBase.getPriceForRateLimit(20, mockContractFactory);
expect(result.token).to.be.null;
expect(result.price).to.be.null;
});
});

View File

@ -5,8 +5,12 @@ import { IdentityCredential } from "../identity.js";
import { DecryptedCredentials } from "../keystore/types.js";
import { BytesUtils } from "../utils/bytes.js";
import { RLN_ABI } from "./abi.js";
import { DEFAULT_RATE_LIMIT, RATE_LIMIT_PARAMS } from "./constants.js";
import { RLN_ABI } from "./abi/rln.js";
import {
DEFAULT_RATE_LIMIT,
PRICE_CALCULATOR_CONTRACT,
RATE_LIMIT_PARAMS
} from "./constants.js";
import {
CustomQueryOptions,
FetchMembersOptions,
@ -770,4 +774,31 @@ export class RLNBaseContract {
return false;
}
}
/**
* Calculates the price for a given rate limit using the PriceCalculator contract
* @param rateLimit The rate limit to calculate the price for
* @param contractFactory Optional factory for creating the contract (for testing)
*/
public async getPriceForRateLimit(
rateLimit: number,
contractFactory?: typeof import("ethers").Contract
): Promise<{
token: string | null;
price: import("ethers").BigNumber | null;
}> {
const provider = this.contract.provider;
const ContractCtor = contractFactory || ethers.Contract;
const priceCalculator = new ContractCtor(
PRICE_CALCULATOR_CONTRACT.address,
PRICE_CALCULATOR_CONTRACT.abi,
provider
);
const [token, price] = await priceCalculator.calculate(rateLimit);
// Defensive: if token or price is null/undefined, return nulls
if (!token || !price) {
return { token: null, price: null };
}
return { token, price };
}
}

View File

@ -5,7 +5,7 @@ import sinon from "sinon";
import { createRLN } from "../create.js";
import type { IdentityCredential } from "../identity.js";
import { DEFAULT_RATE_LIMIT, LINEA_CONTRACT } from "./constants.js";
import { DEFAULT_RATE_LIMIT, RLN_CONTRACT } from "./constants.js";
import { RLNContract } from "./rln_contract.js";
export interface TestRLNInstance {
@ -42,7 +42,7 @@ export async function initializeRLNContract(
mockedRegistryContract: ethers.Contract
): Promise<RLNContract> {
const provider = new ethers.providers.JsonRpcProvider();
const voidSigner = new ethers.VoidSigner(LINEA_CONTRACT.address, provider);
const voidSigner = new ethers.VoidSigner(RLN_CONTRACT.address, provider);
const originalRegister = mockedRegistryContract.register;
(mockedRegistryContract as any).register = function (...args: any[]) {
@ -63,7 +63,7 @@ export async function initializeRLNContract(
};
const contract = await RLNContract.init(rlnInstance, {
address: LINEA_CONTRACT.address,
address: RLN_CONTRACT.address,
signer: voidSigner,
rateLimit: DEFAULT_RATE_LIMIT,
contract: mockedRegistryContract

View File

@ -5,7 +5,7 @@ import sinon from "sinon";
import type { IdentityCredential } from "../identity.js";
import { DEFAULT_RATE_LIMIT, LINEA_CONTRACT } from "./constants.js";
import { DEFAULT_RATE_LIMIT, RLN_CONTRACT } from "./constants.js";
export const mockRateLimits = {
minRate: 20,
@ -36,9 +36,9 @@ export function createMockProvider(): MockProvider {
export function createMockFilters(): MockFilters {
return {
MembershipRegistered: () => ({ address: LINEA_CONTRACT.address }),
MembershipErased: () => ({ address: LINEA_CONTRACT.address }),
MembershipExpired: () => ({ address: LINEA_CONTRACT.address })
MembershipRegistered: () => ({ address: RLN_CONTRACT.address }),
MembershipErased: () => ({ address: RLN_CONTRACT.address }),
MembershipExpired: () => ({ address: RLN_CONTRACT.address })
};
}
@ -51,9 +51,9 @@ export function createMockRegistryContract(
overrides: ContractOverrides = {}
): ethers.Contract {
const filters = {
MembershipRegistered: () => ({ address: LINEA_CONTRACT.address }),
MembershipErased: () => ({ address: LINEA_CONTRACT.address }),
MembershipExpired: () => ({ address: LINEA_CONTRACT.address })
MembershipRegistered: () => ({ address: RLN_CONTRACT.address }),
MembershipErased: () => ({ address: RLN_CONTRACT.address }),
MembershipExpired: () => ({ address: RLN_CONTRACT.address })
};
const baseContract = {
@ -89,7 +89,7 @@ export function createMockRegistryContract(
format: () => {}
})
},
address: LINEA_CONTRACT.address
address: RLN_CONTRACT.address
};
// Merge overrides while preserving filters
@ -163,7 +163,7 @@ export function verifyRegistration(
expect(decryptedCredentials).to.have.property("identity");
expect(decryptedCredentials).to.have.property("membership");
expect(decryptedCredentials.membership).to.include({
address: LINEA_CONTRACT.address,
address: RLN_CONTRACT.address,
treeIndex: 1
});

View File

@ -3,7 +3,7 @@ import { sha256 } from "@noble/hashes/sha2";
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { LINEA_CONTRACT, RLN_Q } from "./contract/constants.js";
import { RLN_CONTRACT, RLN_Q } from "./contract/constants.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
@ -152,10 +152,10 @@ export class RLNCredentialsManager {
const address =
credentials?.membership.address ||
options.address ||
LINEA_CONTRACT.address;
RLN_CONTRACT.address;
if (address === LINEA_CONTRACT.address) {
chainId = LINEA_CONTRACT.chainId.toString();
if (address === RLN_CONTRACT.address) {
chainId = RLN_CONTRACT.chainId.toString();
log.info(`Using Linea contract with chainId: ${chainId}`);
}

View File

@ -1,6 +1,6 @@
import { RLNDecoder, RLNEncoder } from "./codec.js";
import { RLN_ABI } from "./contract/abi.js";
import { LINEA_CONTRACT, RLNContract } from "./contract/index.js";
import { RLN_ABI } from "./contract/abi/rln.js";
import { RLN_CONTRACT, RLNContract } from "./contract/index.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { createRLN } from "./create.js";
import { RLNCredentialsManager } from "./credentials_manager.js";
@ -23,7 +23,7 @@ export {
RLNDecoder,
MerkleRootTracker,
RLNContract,
LINEA_CONTRACT,
RLN_CONTRACT,
extractMetaMaskSigner,
RLN_ABI
};