Skip to content

Commit a7746d5

Browse files
authored
Merge pull request #269 from sprintertech/feat/netter
Feat/netter
2 parents c67373f + 42ac362 commit a7746d5

10 files changed

Lines changed: 432 additions & 7 deletions

File tree

contracts/Netter.sol

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-License-Identifier: LGPL-3.0-only
2+
pragma solidity 0.8.28;
3+
4+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
6+
import {IOracle} from "./interfaces/IOracle.sol";
7+
import {IProcessor} from "./interfaces/IProcessor.sol";
8+
9+
/// @title Netter — nets two Processor balances against each other at oracle fair value.
10+
/// @notice Instead of selling tokens on the open market, two processors can swap their
11+
/// accumulated foreign-asset balances through this contract, avoiding market slippage.
12+
/// The oracle ensures both sides carry equal value before any transfer is made.
13+
/// @author Sprinter
14+
contract Netter is AccessControl {
15+
IOracle public immutable ORACLE;
16+
17+
bytes32 public constant CALLER_ROLE = "CALLER_ROLE";
18+
19+
error ZeroAddress();
20+
error ValuesNotEqual();
21+
22+
event Netted(
23+
address processorA,
24+
address processorB,
25+
uint256 amountFromA,
26+
uint256 amountFromB
27+
);
28+
29+
constructor(address oracle, address admin, address caller) {
30+
require(oracle != address(0), ZeroAddress());
31+
require(admin != address(0), ZeroAddress());
32+
ORACLE = IOracle(oracle);
33+
_grantRole(DEFAULT_ADMIN_ROLE, admin);
34+
_grantRole(CALLER_ROLE, caller);
35+
}
36+
37+
/// @notice Net processorA's balance of processorB's asset against processorB's balance of processorA's asset.
38+
/// @param processorA Processor that holds amountFromA of processorB.TARGET_ASSET and will forward it.
39+
/// @param processorB Processor that holds amountFromB of processorA.TARGET_ASSET and will forward it.
40+
/// @param amountFromA Amount of processorB.TARGET_ASSET that processorA forwards.
41+
/// @param amountFromB Amount of processorA.TARGET_ASSET that processorB forwards.
42+
function net(
43+
IProcessor processorA,
44+
IProcessor processorB,
45+
uint256 amountFromA,
46+
uint256 amountFromB
47+
) external onlyRole(CALLER_ROLE) {
48+
IERC20 assetA = processorA.TARGET_ASSET();
49+
IERC20 assetB = processorB.TARGET_ASSET();
50+
51+
uint256 precision = 10**12;
52+
uint256 valueA = ORACLE.getAssetValue(_toAssetId(assetA), amountFromB * precision);
53+
uint256 valueB = ORACLE.getAssetValue(_toAssetId(assetB), amountFromA * precision);
54+
require(valueA == valueB, ValuesNotEqual());
55+
56+
processorA.forwardAmount(assetB, amountFromA);
57+
processorB.forwardAmount(assetA, amountFromB);
58+
59+
emit Netted(address(processorA), address(processorB), amountFromA, amountFromB);
60+
}
61+
62+
function _toAssetId(IERC20 token) internal pure returns (bytes32) {
63+
return bytes32(uint256(uint160(address(token))));
64+
}
65+
}

contracts/Processor.sol

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ contract Processor is AccessControlUpgradeable, MulticallUpgradeable {
3434

3535
bytes32 private constant STORAGE_LOCATION = 0x315f94a0eb28ebc9ade3d51c8bd7ef4c2011752d9ac5c2acc448e4822cfa1f00;
3636

37-
event Forwarded(address caller, IERC20 token);
37+
event Forwarded(address caller, IERC20 token, uint256 amount);
3838
event Processed(address caller, IERC20 tokenIn, uint256 amountIn, uint256 amountOut);
3939
event MaxSlippageSet(uint256 maxSlippage);
4040
event AdminProcessed(address caller);
@@ -94,9 +94,13 @@ contract Processor is AccessControlUpgradeable, MulticallUpgradeable {
9494
_setMaxSlippage(_getStorage(), newMaxSlippage);
9595
}
9696

97-
function forward(IERC20 token) external onlyRole(CALLER_ROLE) {
98-
_finalizeTransfer(token, token.balanceOf(address(this)));
99-
emit Forwarded(msg.sender, token);
97+
function forward(IERC20 token) external {
98+
forwardAmount(token, token.balanceOf(address(this)));
99+
}
100+
101+
function forwardAmount(IERC20 token, uint256 amount) public onlyRole(CALLER_ROLE) {
102+
_finalizeTransfer(token, amount);
103+
emit Forwarded(msg.sender, token, amount);
100104
}
101105

102106
function redeem7540(IERC7540 token) external onlyRole(CALLER_ROLE) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-License-Identifier: LGPL-3.0-only
2+
pragma solidity 0.8.28;
3+
4+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
6+
interface IProcessor {
7+
function TARGET_ASSET() external view returns (IERC20);
8+
function forwardAmount(IERC20 token, uint256 amount) external;
9+
}

coverage-baseline.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"lines": "99.71",
3-
"functions": "99.69",
4-
"branches": "93.69",
3+
"functions": "99.70",
4+
"branches": "93.53",
55
"statements": "99.71"
66
}

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
"deploy-pyusd-stashdex-processor-ethereum-stage": "PROCESSOR_TOKEN=PYUSD DEPLOY_TYPE=STAGE hardhat run ./scripts/deployStashDexProcessor.ts --network ETHEREUM",
8484
"deploy-stashdex-ethereum": "hardhat run ./scripts/deployStashDex.ts --network ETHEREUM",
8585
"deploy-stashdex-ethereum-stage": "DEPLOY_TYPE=STAGE hardhat run ./scripts/deployStashDex.ts --network ETHEREUM",
86+
"deploy-netter-ethereum": "hardhat run ./scripts/deployNetter.ts --network ETHEREUM",
87+
"deploy-netter-ethereum-stage": "DEPLOY_TYPE=STAGE hardhat run ./scripts/deployNetter.ts --network ETHEREUM",
8688
"deploy-paxosoracle-ethereum": "hardhat run ./scripts/deployPaxosOracle.ts --network ETHEREUM",
8789
"deploy-paxosoracle-ethereum-stage": "DEPLOY_TYPE=STAGE hardhat run ./scripts/deployPaxosOracle.ts --network ETHEREUM",
8890
"upgrade-liquidityhub-basesepolia": "hardhat run ./scripts/upgradeLiquidityHub.ts --network BASE_SEPOLIA",
@@ -257,6 +259,8 @@
257259
"dry:deploy-pyusd-stashdex-processor-ethereum": "DRY_RUN=ETHEREUM VERIFY=false PROCESSOR_TOKEN=PYUSD ts-node --files ./scripts/deployStashDexProcessor.ts",
258260
"dry:deploy-stashdex-ethereum": "DRY_RUN=ETHEREUM VERIFY=false ts-node --files ./scripts/deployStashDex.ts",
259261
"dry:deploy-stashdex-ethereum-stage": "DRY_RUN=ETHEREUM DEPLOY_TYPE=STAGE VERIFY=false ts-node --files ./scripts/deployStashDex.ts",
262+
"dry:deploy-netter-ethereum": "DRY_RUN=ETHEREUM VERIFY=false ts-node --files ./scripts/deployNetter.ts",
263+
"dry:deploy-netter-ethereum-stage": "DRY_RUN=ETHEREUM DEPLOY_TYPE=STAGE VERIFY=false ts-node --files ./scripts/deployNetter.ts",
260264
"dry:upgrade-liquidityhub-basesepolia": "DRY_RUN=BASE_SEPOLIA VERIFY=false ts-node --files ./scripts/upgradeLiquidityHub.ts",
261265
"dry:upgrade-liquidityhub-base": "DRY_RUN=BASE VERIFY=false ts-node --files ./scripts/upgradeLiquidityHub.ts",
262266
"dry:upgrade-liquidityhub-base-stage": "DRY_RUN=BASE DEPLOY_TYPE=STAGE VERIFY=false ts-node --files ./scripts/upgradeLiquidityHub.ts",

scripts/deployNetter.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import dotenv from "dotenv";
2+
dotenv.config();
3+
import hre from "hardhat";
4+
import {NonceManager} from "ethers";
5+
import {getVerifier, getHardhatNetworkConfig, getNetworkConfig, logDeployers} from "./helpers";
6+
import {resolveXAddress} from "../test/helpers";
7+
import {isSet, assert, assertAddress, DEFAULT_ADMIN_ROLE} from "./common";
8+
import {Netter} from "../typechain-types";
9+
import {Network, NetworkConfig} from "../network.config";
10+
11+
export async function main() {
12+
const [deployer] = await hre.ethers.getSigners();
13+
const deployerWithNonce = new NonceManager(deployer);
14+
15+
assert(isSet(process.env.DEPLOY_ID), "DEPLOY_ID must be set");
16+
const verifier = getVerifier(process.env.DEPLOY_ID);
17+
console.log(`Deployment ID: ${process.env.DEPLOY_ID}`);
18+
const id = "Netter";
19+
20+
let network: Network;
21+
let config: NetworkConfig;
22+
console.log("Deploying Netter");
23+
({network, config} = await getNetworkConfig());
24+
if (!network) {
25+
({network, config} = await getHardhatNetworkConfig());
26+
}
27+
await logDeployers();
28+
29+
assert(config.StashDex, "StashDex must be in config");
30+
assertAddress(config.Admin, "Admin must be an address");
31+
assertAddress(config.RepayerCaller, "RepayerCaller must be an address");
32+
33+
const oracle = await resolveXAddress(config.StashDex.Oracle);
34+
35+
const netter = (await verifier.deployX(
36+
"Netter",
37+
deployerWithNonce,
38+
{},
39+
[oracle, config.Admin, config.RepayerCaller],
40+
id,
41+
)) as Netter;
42+
console.log(`${id}: ${netter.target}`);
43+
44+
const uniqueProcessors = [
45+
...new Set(await Promise.all(config.StashDex.Routes.map(r => resolveXAddress(r.Processor)))),
46+
];
47+
48+
const CALLER_ROLE = hre.ethers.encodeBytes32String("CALLER_ROLE");
49+
50+
const granted: string[] = [];
51+
const needsInstruction: string[] = [];
52+
53+
for (const processorAddr of uniqueProcessors) {
54+
const processor = await hre.ethers.getContractAt("AccessControlUpgradeable", processorAddr);
55+
if (await processor.hasRole(CALLER_ROLE, netter)) {
56+
console.log(`CALLER_ROLE already granted to Netter on ${processorAddr} — skipping`);
57+
continue;
58+
}
59+
if (await processor.hasRole(DEFAULT_ADMIN_ROLE, deployerWithNonce)) {
60+
await processor.grantRole(CALLER_ROLE, netter);
61+
granted.push(processorAddr);
62+
} else {
63+
needsInstruction.push(processorAddr);
64+
}
65+
}
66+
67+
if (granted.length > 0) {
68+
console.log("Granted CALLER_ROLE to Netter on processors:");
69+
console.table(granted.map(addr => ({processor: addr})));
70+
}
71+
72+
if (needsInstruction.length > 0) {
73+
const anyProcessor = await hre.ethers.getContractAt("AccessControlUpgradeable", needsInstruction[0]);
74+
const calldata = (await anyProcessor.grantRole.populateTransaction(CALLER_ROLE, netter)).data;
75+
console.log("NEXT STEPS — grant CALLER_ROLE to Netter on each processor:");
76+
console.log(`Calldata: ${calldata}`);
77+
console.table(needsInstruction.map(addr => ({processor: addr})));
78+
}
79+
80+
await verifier.verify(process.env.VERIFY === "true");
81+
}
82+
83+
if (process.env.SCRIPT_ENV !== "CI") {
84+
main();
85+
}

scripts/test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {main as deployStashDex} from "./deployStashDex";
2222
import {main as deployStashDexProcessor} from "./deployStashDexProcessor";
2323
import {main as upgradeStashDexProcessor} from "./upgradeStashDexProcessor";
2424
import {main as upgradeStashDex} from "./upgradeStashDex";
25+
import {main as deployNetter} from "./deployNetter";
2526

2627
async function main() {
2728
console.log("Test deploy.");
@@ -63,6 +64,8 @@ async function main() {
6364
await upgradeStashDexProcessor();
6465
console.log("Test upgradeStashDex.");
6566
await upgradeStashDex();
67+
console.log("Test deployNetter.");
68+
await deployNetter();
6669
console.log("Test deployStandaloneRepayer.");
6770
process.env.STANDALONE_REPAYER_ENV = "SparkStage";
6871
await deployStandaloneRepayer();

test/Netter.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import hre from "hardhat";
2+
import {expect} from "chai";
3+
import {getContractAt, deploy, toBytes32, setupTests} from "./helpers";
4+
import {
5+
TestUSDC, TestWETH, PaxosOracle, Processor, Netter, TransparentUpgradeableProxy,
6+
} from "../typechain-types";
7+
import {loadFixture} from "@nomicfoundation/hardhat-toolbox/network-helpers";
8+
import {addressToBytes32, ZERO_ADDRESS} from "../scripts/common";
9+
10+
describe("Netter", function () {
11+
setupTests();
12+
13+
const USDC = 10n ** 6n;
14+
const WETH = 10n ** 18n;
15+
16+
const deployAll = async () => {
17+
const [deployer, admin, caller, user, receiverA, receiverB] = await hre.ethers.getSigners();
18+
19+
const CALLER_ROLE = toBytes32("CALLER_ROLE");
20+
21+
const usdcRef = (await deploy("TestUSDC", deployer)) as TestUSDC;
22+
const assetA = (await deploy("TestUSDC", deployer)) as TestUSDC; // 6 decimals
23+
const assetB = (await deploy("TestWETH", deployer)) as TestWETH; // 18 decimals
24+
25+
const oracle = (await deploy("PaxosOracle", deployer, {}, admin, usdcRef, [
26+
{assetId: addressToBytes32(assetA.target), decimals: 6},
27+
{assetId: addressToBytes32(assetB.target), decimals: 18},
28+
])) as PaxosOracle;
29+
30+
const deployProcessor = async (asset: TestUSDC | TestWETH, receiver: typeof receiverA) => {
31+
const impl = (await deploy("Processor", deployer, {}, asset, receiver, oracle)) as Processor;
32+
const initData = (await impl.initialize.populateTransaction(admin, admin, admin)).data;
33+
const proxy = (await deploy(
34+
"TransparentUpgradeableProxy", deployer, {}, impl, admin, initData,
35+
)) as TransparentUpgradeableProxy;
36+
return (await getContractAt("Processor", proxy, deployer)) as Processor;
37+
};
38+
39+
const processorA = await deployProcessor(assetA, receiverA);
40+
const processorB = await deployProcessor(assetB, receiverB);
41+
42+
const netter = (await deploy("Netter", deployer, {}, oracle, admin, caller)) as Netter;
43+
44+
await processorA.connect(admin).grantRole(CALLER_ROLE, netter);
45+
await processorB.connect(admin).grantRole(CALLER_ROLE, netter);
46+
47+
return {
48+
deployer, admin, caller, user, receiverA, receiverB,
49+
assetA, assetB, oracle, processorA, processorB, netter,
50+
};
51+
};
52+
53+
describe("constructor", function () {
54+
it("reverts ZeroAddress when oracle is zero", async function () {
55+
const {admin, caller, netter} = await loadFixture(deployAll);
56+
await expect(deploy("Netter", admin, {}, ZERO_ADDRESS, admin, caller))
57+
.to.be.revertedWithCustomError(netter, "ZeroAddress");
58+
});
59+
60+
it("reverts ZeroAddress when admin is zero", async function () {
61+
const {oracle, caller, netter} = await loadFixture(deployAll);
62+
await expect(deploy("Netter", caller, {}, oracle, ZERO_ADDRESS, caller))
63+
.to.be.revertedWithCustomError(netter, "ZeroAddress");
64+
});
65+
66+
it("stores ORACLE and grants DEFAULT_ADMIN_ROLE and CALLER_ROLE", async function () {
67+
const {netter, oracle, admin, caller} = await loadFixture(deployAll);
68+
const DEFAULT_ADMIN_ROLE = await netter.DEFAULT_ADMIN_ROLE();
69+
const CALLER_ROLE = await netter.CALLER_ROLE();
70+
71+
expect(await netter.ORACLE()).to.equal(oracle.target);
72+
expect(await netter.hasRole(DEFAULT_ADMIN_ROLE, admin)).to.be.true;
73+
expect(await netter.hasRole(CALLER_ROLE, caller)).to.be.true;
74+
expect(await netter.hasRole(CALLER_ROLE, admin)).to.be.false;
75+
});
76+
});
77+
78+
describe("net", function () {
79+
it("reverts AccessControlUnauthorizedAccount when caller lacks CALLER_ROLE", async function () {
80+
const {netter, user, processorA, processorB} = await loadFixture(deployAll);
81+
await expect(netter.connect(user).net(processorA, processorB, 1n, 1n))
82+
.to.be.revertedWithCustomError(netter, "AccessControlUnauthorizedAccount");
83+
});
84+
85+
it("reverts ValuesNotEqual when oracle values do not match", async function () {
86+
const {netter, caller, processorA, processorB} = await loadFixture(deployAll);
87+
await expect(netter.connect(caller).net(processorA, processorB, 3_000n * WETH + 1n, 3_000n * USDC))
88+
.to.be.revertedWithCustomError(netter, "ValuesNotEqual");
89+
await expect(netter.connect(caller).net(processorA, processorB, 3_000n * WETH - 1n, 3_000n * USDC))
90+
.to.be.revertedWithCustomError(netter, "ValuesNotEqual");
91+
});
92+
93+
it("forwards on both processors and emits Netted when values are equal", async function () {
94+
const {netter, caller, processorA, processorB, assetA, assetB, receiverA, receiverB} =
95+
await loadFixture(deployAll);
96+
97+
const amountFromA = 4_000n * WETH;
98+
const amountFromB = 4_000n * USDC;
99+
100+
await assetB.mint(processorA, amountFromA);
101+
await assetA.mint(processorB, amountFromB);
102+
103+
const tx = await netter.connect(caller).net(processorA, processorB, amountFromA, amountFromB);
104+
105+
await expect(tx).to.emit(netter, "Netted")
106+
.withArgs(processorA.target, processorB.target, amountFromA, amountFromB);
107+
await expect(tx).to.emit(processorA, "Forwarded")
108+
.withArgs(netter.target, assetB.target, amountFromA);
109+
await expect(tx).to.emit(processorB, "Forwarded")
110+
.withArgs(netter.target, assetA.target, amountFromB);
111+
112+
expect(await assetB.balanceOf(processorA)).to.equal(0n);
113+
expect(await assetA.balanceOf(processorB)).to.equal(0n);
114+
expect(await assetB.balanceOf(receiverA)).to.equal(amountFromA);
115+
expect(await assetA.balanceOf(receiverB)).to.equal(amountFromB);
116+
});
117+
118+
it("forwards on both processors and emits Netted when values are equal, swapped order", async function () {
119+
const {netter, caller, processorA, processorB, assetA, assetB, receiverA, receiverB} =
120+
await loadFixture(deployAll);
121+
122+
const amountFromA = 4_000n * WETH;
123+
const amountFromB = 4_000n * USDC;
124+
125+
await assetB.mint(processorA, amountFromA);
126+
await assetA.mint(processorB, amountFromB);
127+
128+
const tx = await netter.connect(caller).net(processorB, processorA, amountFromB, amountFromA);
129+
130+
await expect(tx).to.emit(netter, "Netted")
131+
.withArgs(processorB.target, processorA.target, amountFromB, amountFromA);
132+
await expect(tx).to.emit(processorA, "Forwarded")
133+
.withArgs(netter.target, assetB.target, amountFromA);
134+
await expect(tx).to.emit(processorB, "Forwarded")
135+
.withArgs(netter.target, assetA.target, amountFromB);
136+
137+
expect(await assetB.balanceOf(processorA)).to.equal(0n);
138+
expect(await assetA.balanceOf(processorB)).to.equal(0n);
139+
expect(await assetB.balanceOf(receiverA)).to.equal(amountFromA);
140+
expect(await assetA.balanceOf(receiverB)).to.equal(amountFromB);
141+
});
142+
143+
it("leaves unrelated balances untouched", async function () {
144+
const {netter, caller, processorA, processorB, assetA, assetB, receiverA, receiverB} =
145+
await loadFixture(deployAll);
146+
147+
const amountFromA = 4_000n * WETH;
148+
const amountFromB = 4_000n * USDC;
149+
150+
// processorA also holds some assetA; processorB also holds some assetB
151+
await assetB.mint(processorA, amountFromA);
152+
await assetA.mint(processorA, 1_000n * USDC);
153+
await assetA.mint(processorB, amountFromB);
154+
await assetB.mint(processorB, 2_000n * WETH);
155+
156+
await netter.connect(caller).net(processorA, processorB, amountFromA, amountFromB);
157+
158+
expect(await assetA.balanceOf(processorA)).to.equal(1_000n * USDC);
159+
expect(await assetB.balanceOf(processorB)).to.equal(2_000n * WETH);
160+
expect(await assetA.balanceOf(receiverA)).to.equal(0n);
161+
expect(await assetB.balanceOf(receiverB)).to.equal(0n);
162+
});
163+
});
164+
});

0 commit comments

Comments
 (0)