Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/api/src/api/services/monerium-b2b/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const forwarderAbi = [
type: "function"
},
{ inputs: [], name: "forwardAll", outputs: [], stateMutability: "nonpayable", type: "function" },
{ inputs: [], name: "applyFeePolicy", outputs: [], stateMutability: "nonpayable", type: "function" },
{
inputs: [
{ name: "eureAmount", type: "uint256" },
Expand Down Expand Up @@ -166,7 +167,14 @@ export const forwarderAbi = [
type: "function"
},
{ inputs: [], name: "targetPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" },
{ inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" }
{ inputs: [], name: "floorPpm", outputs: [{ name: "", type: "uint32" }], stateMutability: "view", type: "function" },
{
inputs: [],
name: "pendingFeePolicyEffectiveAt",
outputs: [{ name: "", type: "uint64" }],
stateMutability: "view",
type: "function"
}
] as const;

export const factoryAbi = [
Expand Down
151 changes: 151 additions & 0 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,7 @@ describe("runConversionExecutor activation gate", () => {
latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: a planned swap defers before sending anything
MIN_SWAP_FLOOR: 1n,
minSwapAmount: 25n * EUR,
pendingFeePolicyEffectiveAt: 0n,
perSwapCap: 10_000n * EUR,
subsidyVault: "0x0000000000000000000000000000000000000000",
targetPpm: 1_250
Expand Down Expand Up @@ -999,6 +1000,156 @@ describe("runConversionExecutor activation gate", () => {
});
});

// P11: an announced fee increase only takes effect once someone calls the permissionless
// applyFeePolicy() after the timelock; the keeper does so in the account's cycle.
describe("runConversionExecutor fee-policy apply", () => {
const FACTORY = "0x2222222222222222222222222222222222222222" as Address;
const EURE = "0x4444444444444444444444444444444444444444" as Address;
const NOW = 1_800_000_000n;
const saved = { factory: config.moneriumB2b.forwarderFactoryAddress, rpcUrl: config.moneriumB2b.rpcUrl };
let reads: Record<string, unknown>;
let writes: string[];
let writeError: Error | null;
let nonces: { latest: number; pending: number };
let receiptTimesOut: boolean;

beforeAll(async () => {
config.moneriumB2b.rpcUrl = undefined;
config.moneriumB2b.forwarderFactoryAddress = FACTORY;
await setupTestDatabase();
});

afterAll(() => {
config.moneriumB2b.rpcUrl = saved.rpcUrl;
config.moneriumB2b.forwarderFactoryAddress = saved.factory;
});

beforeEach(async () => {
await resetTestDatabase();
writes = [];
writeError = null;
nonces = { latest: 0, pending: 0 };
receiptTimesOut = false;
reads = {
batchOpenedAt: 1n,
floorPpm: 1_500,
latestRoundData: [1n, 0n, 0n, 0n, 1n], // Chainlink down: the planned swap defers, nothing else is sent
MIN_SWAP_FLOOR: 1n,
minSwapAmount: 25n * EUR,
pendingFeePolicyEffectiveAt: 0n,
perSwapCap: 10_000n * EUR,
subsidyVault: "0x0000000000000000000000000000000000000000",
targetPpm: 1_250
};
spyOn(chain, "getForwarderImmutables").mockResolvedValue({
eure: EURE,
factory: FACTORY,
usdc: "0x6666666666666666666666666666666666666666",
recoveryDelaySeconds: 7_200
} as unknown as chain.ForwarderImmutables);
spyOn(chain, "getPublicClient").mockReturnValue({
getBlock: async () => ({ timestamp: NOW }),
getTransactionCount: async ({ blockTag }: { blockTag: "latest" | "pending" }) => nonces[blockTag],
readContract: async ({ address, functionName }: { address: Address; functionName: string }) => {
if (functionName === "targetPpm") writes.push("read targetPpm");
return functionName === "balanceOf" ? (address === EURE ? 100n * EUR : 0n) : reads[functionName];
},
simulateContract: async () => ({}),
waitForTransactionReceipt: async () => {
if (receiptTimesOut) throw new Error("WaitForTransactionReceiptTimeoutError");
nonces.latest = nonces.pending;
return { status: "success" };
}
} as unknown as ReturnType<typeof chain.getPublicClient>);
spyOn(chain, "getKeeperWalletClient").mockReturnValue({
account: { address: "0x9999999999999999999999999999999999999999" },
writeContract: async ({ functionName }: { functionName: string }) => {
if (writeError) throw writeError;
writes.push(functionName);
nonces.pending += 1;
return "0xapply";
}
} as unknown as ReturnType<typeof chain.getKeeperWalletClient>);
});

afterEach(() => mock.restore());

async function activeAccountWithDeposit() {
const manager = await createTestUser();
await ManagedProfileManager.create({
allowedCorridors: ["EU"],
allowedCustomerTypes: ["business"],
isActive: true,
profileId: manager.id
});
const { accountId } = await provisionMoneriumB2bAccount({
contactEmail: "ops@client.example.com",
destination: "0x5555555555555555555555555555555555555555",
externalSubjectId: "client-1",
forwarderAddress: "0x1111111111111111111111111111111111111111",
managerProfileId: manager.id,
moneriumProfileId: "0b8e7c2a-8f4e-4d43-9f2b-2f9f3c1d5a6e"
});
await MoneriumAccount.update({ iban: "EE08 7224 5745 6244 9516", status: MoneriumAccountStatus.Active }, { where: { id: accountId } });
const deposit = await MoneriumFiatDeposit.create({
accountId,
amountRaw: (100n * EUR).toString(),
blockNumber: 100,
chainId: 11155111,
currency: "eur",
logIndex: 1,
mintedAt: new Date(),
moneriumOrderId: "order-1",
payerIban: "DE89370400440532013000",
payerName: "Payer GmbH",
status: MoneriumFiatDepositStatus.Minted,
txHash: "0xorder1"
});
return { accountId, deposit };
}

it("applies a due increase once, before the cycle prices its swap", async () => {
reads.pendingFeePolicyEffectiveAt = NOW;
const { accountId, deposit } = await activeAccountWithDeposit();

await runConversionExecutor(accountId);

expect(writes).toEqual(["applyFeePolicy", "read targetPpm"]);
expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable"); // the cycle went on
});

it("does not re-send while an earlier apply is still unmined", async () => {
reads.pendingFeePolicyEffectiveAt = NOW;
receiptTimesOut = true;
const { accountId } = await activeAccountWithDeposit();

await runConversionExecutor(accountId); // sent, receipt wait times out
await runConversionExecutor(accountId); // still looks pending on chain

expect(writes.filter(write => write === "applyFeePolicy")).toEqual(["applyFeePolicy"]);
});

it("sends nothing while no increase is pending or its timelock still runs", async () => {
const { accountId } = await activeAccountWithDeposit();
await runConversionExecutor(accountId);
reads.pendingFeePolicyEffectiveAt = NOW + 1n;
await runConversionExecutor(accountId);
expect(writes.filter(write => write === "applyFeePolicy")).toEqual([]);
});

it("logs a reverted apply and carries on with the cycle", async () => {
reads.pendingFeePolicyEffectiveAt = NOW - 60n;
writeError = new Error("NoPendingFeePolicy()");
const warn = spyOn(logger, "warn");
const { accountId, deposit } = await activeAccountWithDeposit();

await runConversionExecutor(accountId);

expect(warn.mock.calls.some(([message]) => String(message).includes("applyFeePolicy"))).toBe(true);
expect((await deposit.reload()).waitingReason).toBe("oracle_unavailable");
});
});

// A reservation whose broadcast never happened (the process died between `reserve` and
// `send`) holds a nonce nobody will use; the keeper re-sends the identical call there.
describe("runConversionExecutor reserved-nonce re-send", () => {
Expand Down
44 changes: 42 additions & 2 deletions apps/api/src/api/services/monerium-b2b/conversion-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,15 +1087,19 @@ export async function runConversionExecutor(accountId: string): Promise<void> {
) {
throw new Error(`Forwarder ${forwarder} is not bound to the configured trusted factory`);
}
const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap] = await Promise.all([
const [eureBalance, usdcBalance, batchOpenedAt, minSwapAmount, minSwapFloor, perSwapCap, feeIncreaseAt] = await Promise.all([
client.readContract({ abi: erc20Abi, address: eure, args: [forwarder], functionName: "balanceOf" }),
client.readContract({ abi: erc20Abi, address: usdc, args: [forwarder], functionName: "balanceOf" }),
client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "batchOpenedAt" }),
client.readContract({ abi: factoryAbi, address: factory, functionName: "minSwapAmount" }),
client.readContract({ abi: factoryAbi, address: factory, functionName: "MIN_SWAP_FLOOR" }),
client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" })
client.readContract({ abi: factoryAbi, address: factory, functionName: "perSwapCap" }),
client.readContract({ abi: forwarderAbi, address: forwarder, functionName: "pendingFeePolicyEffectiveAt" })
]);

// Before pricing, so a swap this cycle is projected under the policy it will settle at.
await applyDueFeePolicy(forwarder, feeIncreaseAt);

// Arm the batch marker whenever funds are present, even below the (guardian-tunable)
// minSwapAmount: the recovery and trigger clocks must run regardless of whether a swap
// is currently possible.
Expand Down Expand Up @@ -1389,6 +1393,42 @@ async function sendPoke(forwarder: Address): Promise<void> {
}
}

/**
* P11: finalizes a guardian fee increase whose timelock has elapsed (`applyFeePolicy` is
* permissionless; nothing else would ever apply it). Waits for the receipt so the cycle
* then reads the applied policy, and skips while any keeper transaction is still unmined so
* a timed-out apply is not re-sent every cycle. Best-effort like sendPoke: a revert (applied
* by someone else, clock skew) or a timeout is logged and the cycle goes on, since the
* contract always settles under its current on-chain policy. The monitor's config reconciliation mirrors
* the applied values into the account row.
*/
async function applyDueFeePolicy(forwarder: Address, effectiveAt: bigint): Promise<void> {
if (effectiveAt === 0n) return;
try {
const client = getPublicClient();
const { timestamp } = await client.getBlock();
if (timestamp < effectiveAt) return;
const keeper = getKeeperWalletClient();
const call = { abi: forwarderAbi, account: keeper.account, address: forwarder, functionName: "applyFeePolicy" } as const;
await client.simulateContract(call);
const hash = await withKeeperSendLock(async () => {
const [latest, pending] = await Promise.all([
client.getTransactionCount({ address: keeper.account.address, blockTag: "latest" }),
client.getTransactionCount({ address: keeper.account.address, blockTag: "pending" })
]);
// An earlier apply (or any keeper send) still unmined: re-sending would only stack nonces.
if (pending !== latest) return null;
return keeper.writeContract({ ...call, chain: null });
});
if (hash === null) return;
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS });
if (receipt.status !== "success") throw new Error(`transaction ${hash} reverted`);
logger.info(`monerium-b2b: applied the pending fee policy on forwarder ${forwarder} (${hash})`);
} catch (error) {
logger.warn(`monerium-b2b: applyFeePolicy on forwarder ${forwarder} failed: ${errorText(error)}`);
}
}

/**
* Marks a settling deposit for the refund path. Under the forwarder lock so it cannot
* race a chunk swap being reserved; the keeper then sends `recover` once the clone's
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/api/services/monerium-b2b/recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ describe("refund deadlines and orchestration", () => {
balanceOf: 100n * EUR,
batchOpenedAt: 1_000n, // long past the recovery delay
minSwapAmount: 25n * EUR,
pendingFeePolicyEffectiveAt: 0n,
perSwapCap: 10_000n * EUR
};
spies.push(
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture-monerium-b2b-onramp.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,11 @@ contract settles every fill into three bands against that reference (decisions:
(permissionlessly) only after the 24 h `FEE_INCREASE_TIMELOCK`, so a client whose
SEPA transfer is already in flight cannot be swapped under a silently worse policy;
lowering is immediate (registry P11). Swaps always use the currently applied policy.
Once an increase is due, the keeper sends `applyFeePolicy()` in the account's next
cycle, before pricing, and waits for it; a revert or timeout is logged and the cycle
goes on, and no apply is re-sent while a keeper transaction is still unmined. The
executor only runs for accounts with a settling deposit, so an idle clone's increase
is applied before its next swap.
- **Subsidy vault (`VortexSubsidyVault`)**: one contract shared by every clone, funded
from the treasury. It pays only when called by a factory-registered clone, to the
clone itself (the subsidy is forwarded with the payment), within a guardian-settable
Expand Down
Loading
Loading