@sentrix-labs/canonical-contracts 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/LICENSE +21 -0
- package/NOTICE +19 -0
- package/README.md +150 -0
- package/contracts/CoinBlastCurve.sol +369 -0
- package/contracts/CoinBlastFactory.sol +76 -0
- package/contracts/MerkleAirdrop.sol +142 -0
- package/contracts/Multicall3.sol +125 -0
- package/contracts/SentrixSafe.sol +252 -0
- package/contracts/StrategicReserveTimelock.sol +104 -0
- package/contracts/TokenFactory.sol +106 -0
- package/contracts/WSRX.sol +95 -0
- package/contracts/interfaces/ISentrixSafe.sol +42 -0
- package/contracts/interfaces/ITokenFactory.sol +27 -0
- package/contracts/interfaces/IWSRX.sol +30 -0
- package/contracts/mocks/MockERC20.sol +60 -0
- package/contracts/mocks/MockSRX.sol +28 -0
- package/deployments/7119.json +75 -0
- package/deployments/7120.json +51 -0
- package/deployments/README.md +70 -0
- package/deployments/abi/FactoryToken.json +1 -0
- package/deployments/abi/Multicall3.json +1 -0
- package/deployments/abi/SentrixSafe.json +1 -0
- package/deployments/abi/TokenFactory.json +1 -0
- package/deployments/abi/WSRX.json +1 -0
- package/deployments/abi/index.js +11 -0
- package/dist/generated.d.ts +3181 -0
- package/dist/generated.d.ts.map +1 -0
- package/dist/index.cjs +1429 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1415 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/generated.ts +1441 -0
- package/src/index.ts +41 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
pragma solidity 0.8.24;
|
|
3
|
+
|
|
4
|
+
import {CoinBlastCurve} from "./CoinBlastCurve.sol";
|
|
5
|
+
|
|
6
|
+
/// @title CoinBlastFactory
|
|
7
|
+
/// @author Sentrix Labs
|
|
8
|
+
/// @notice Canonical deployer for CoinBlast bonding-curve launches.
|
|
9
|
+
/// Wrapping CoinBlastCurve construction here means every
|
|
10
|
+
/// launch fires a single CurveCreated event from a known
|
|
11
|
+
/// contract, so any indexer / launchpad / explorer can scan
|
|
12
|
+
/// this one address to enumerate every curve on chain.
|
|
13
|
+
/// Without it, frontends would have to scrape per-curve
|
|
14
|
+
/// bytecode signatures across every block — tractable but
|
|
15
|
+
/// wasteful when one event source does the job.
|
|
16
|
+
///
|
|
17
|
+
/// @dev The factory is a passthrough: createCurve() does
|
|
18
|
+
/// `new CoinBlastCurve(p)` and emits + records. Each curve
|
|
19
|
+
/// is its own contract; the factory holds no state for the
|
|
20
|
+
/// curve's trading logic. Constructor bounds + reentrancy
|
|
21
|
+
/// guards live on CoinBlastCurve as before.
|
|
22
|
+
contract CoinBlastFactory {
|
|
23
|
+
/// @notice Every curve ever deployed through this factory, oldest first.
|
|
24
|
+
address[] public allCurves;
|
|
25
|
+
/// @dev Curves grouped by msg.sender — i.e. the address that
|
|
26
|
+
/// called createCurve, NOT the address that ends up owning
|
|
27
|
+
/// the underlying ERC-20 supply (the curve itself owns it
|
|
28
|
+
/// per CoinBlastCurve's design).
|
|
29
|
+
mapping(address => address[]) private _curvesOfOwner;
|
|
30
|
+
|
|
31
|
+
/// @notice Fires once per createCurve call. Indexed fields cover
|
|
32
|
+
/// the lookups frontends need most: curve address, token
|
|
33
|
+
/// address, and the deploying EOA.
|
|
34
|
+
event CurveCreated(
|
|
35
|
+
address indexed curve,
|
|
36
|
+
address indexed token,
|
|
37
|
+
address indexed owner,
|
|
38
|
+
string name,
|
|
39
|
+
string symbol,
|
|
40
|
+
uint256 curveSupply,
|
|
41
|
+
uint256 graduationSrxThreshold
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/// @notice Deploy a new CoinBlastCurve and register it. msg.sender
|
|
45
|
+
/// is recorded as the launcher; the curve itself owns the
|
|
46
|
+
/// token supply per CoinBlastCurve mechanics.
|
|
47
|
+
function createCurve(CoinBlastCurve.InitParams memory p)
|
|
48
|
+
external
|
|
49
|
+
returns (CoinBlastCurve curve)
|
|
50
|
+
{
|
|
51
|
+
curve = new CoinBlastCurve(p);
|
|
52
|
+
address curveAddr = address(curve);
|
|
53
|
+
address tokenAddr = address(curve.token());
|
|
54
|
+
allCurves.push(curveAddr);
|
|
55
|
+
_curvesOfOwner[msg.sender].push(curveAddr);
|
|
56
|
+
emit CurveCreated(
|
|
57
|
+
curveAddr,
|
|
58
|
+
tokenAddr,
|
|
59
|
+
msg.sender,
|
|
60
|
+
p.name,
|
|
61
|
+
p.symbol,
|
|
62
|
+
p.curveSupply,
|
|
63
|
+
p.graduationSrxThreshold
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// @notice All curves deployed by `owner` via this factory.
|
|
68
|
+
function curvesOf(address owner) external view returns (address[] memory) {
|
|
69
|
+
return _curvesOfOwner[owner];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// @notice Total curves deployed via this factory.
|
|
73
|
+
function totalCurves() external view returns (uint256) {
|
|
74
|
+
return allCurves.length;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
pragma solidity 0.8.24;
|
|
3
|
+
|
|
4
|
+
/// @title MerkleAirdrop
|
|
5
|
+
/// @author Sentrix Labs
|
|
6
|
+
/// @notice Generic merkle-tree airdrop claim contract for Sentrix airdrop phases.
|
|
7
|
+
/// @dev Minimal & immutable: deploy with merkle root + total amount, pre-fund with
|
|
8
|
+
/// native SRX, recipients claim with a merkle proof. After the claim window
|
|
9
|
+
/// expires, the owner sweeps unclaimed balance back to a configured recipient.
|
|
10
|
+
///
|
|
11
|
+
/// Designed for one phase per deploy — Phase 1 (Testnet Heroes) gets its own
|
|
12
|
+
/// MerkleAirdrop instance pre-funded from Strategic Reserve. Subsequent phases
|
|
13
|
+
/// deploy fresh instances.
|
|
14
|
+
///
|
|
15
|
+
/// Tree leaf format: keccak256(abi.encodePacked(address, uint256))
|
|
16
|
+
/// Proof verification follows OpenZeppelin's MerkleProof convention
|
|
17
|
+
/// (sorted siblings).
|
|
18
|
+
contract MerkleAirdrop {
|
|
19
|
+
// ── Immutable config ─────────────────────────────────────
|
|
20
|
+
bytes32 public immutable merkleRoot;
|
|
21
|
+
uint256 public immutable claimDeadline; // unix timestamp; after this, no more claims
|
|
22
|
+
address public immutable sweepRecipient; // where unclaimed SRX returns (e.g. Strategic Reserve)
|
|
23
|
+
address public immutable owner; // address allowed to call sweep() after deadline
|
|
24
|
+
|
|
25
|
+
// ── State ────────────────────────────────────────────────
|
|
26
|
+
/// @dev address => has-claimed flag
|
|
27
|
+
mapping(address => bool) public claimed;
|
|
28
|
+
|
|
29
|
+
/// @dev cumulative claimed amount (for accounting / introspection)
|
|
30
|
+
uint256 public totalClaimed;
|
|
31
|
+
|
|
32
|
+
/// @dev set true after sweep() is called; locks any further state changes
|
|
33
|
+
bool public swept;
|
|
34
|
+
|
|
35
|
+
// ── Events ───────────────────────────────────────────────
|
|
36
|
+
event Claimed(address indexed recipient, uint256 amount);
|
|
37
|
+
event Swept(address indexed recipient, uint256 amount);
|
|
38
|
+
|
|
39
|
+
// ── Errors ───────────────────────────────────────────────
|
|
40
|
+
error AlreadyClaimed();
|
|
41
|
+
error InvalidProof();
|
|
42
|
+
error ClaimWindowClosed();
|
|
43
|
+
error ClaimWindowOpen();
|
|
44
|
+
error AlreadySwept();
|
|
45
|
+
error NotOwner();
|
|
46
|
+
error TransferFailed();
|
|
47
|
+
error ZeroAddress();
|
|
48
|
+
|
|
49
|
+
// ── Constructor ──────────────────────────────────────────
|
|
50
|
+
/// @param _merkleRoot Root of the airdrop merkle tree. Leaves are
|
|
51
|
+
/// `keccak256(abi.encodePacked(address, uint256))` sorted-pair internal hashes.
|
|
52
|
+
/// @param _claimDeadline Unix timestamp after which claims are rejected.
|
|
53
|
+
/// @param _sweepRecipient Address that receives unclaimed balance after sweep().
|
|
54
|
+
/// @param _owner Address allowed to call sweep() after deadline (typically SentrixSafe).
|
|
55
|
+
constructor(
|
|
56
|
+
bytes32 _merkleRoot,
|
|
57
|
+
uint256 _claimDeadline,
|
|
58
|
+
address _sweepRecipient,
|
|
59
|
+
address _owner
|
|
60
|
+
) {
|
|
61
|
+
if (_sweepRecipient == address(0)) revert ZeroAddress();
|
|
62
|
+
if (_owner == address(0)) revert ZeroAddress();
|
|
63
|
+
merkleRoot = _merkleRoot;
|
|
64
|
+
claimDeadline = _claimDeadline;
|
|
65
|
+
sweepRecipient = _sweepRecipient;
|
|
66
|
+
owner = _owner;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Pre-fund ─────────────────────────────────────────────
|
|
70
|
+
/// @notice Anyone can fund the contract by sending SRX to it.
|
|
71
|
+
/// Typically the Strategic Reserve owner pre-funds at deploy time.
|
|
72
|
+
receive() external payable {}
|
|
73
|
+
|
|
74
|
+
// ── Claim ────────────────────────────────────────────────
|
|
75
|
+
/// @notice Claim airdrop allocation by submitting a merkle proof.
|
|
76
|
+
/// @param amount The allocated amount (in wei) for the calling address.
|
|
77
|
+
/// @param proof Merkle proof showing `(msg.sender, amount)` is in the tree.
|
|
78
|
+
function claim(uint256 amount, bytes32[] calldata proof) external {
|
|
79
|
+
if (block.timestamp > claimDeadline) revert ClaimWindowClosed();
|
|
80
|
+
if (claimed[msg.sender]) revert AlreadyClaimed();
|
|
81
|
+
if (swept) revert AlreadySwept();
|
|
82
|
+
|
|
83
|
+
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
|
|
84
|
+
if (!_verify(proof, merkleRoot, leaf)) revert InvalidProof();
|
|
85
|
+
|
|
86
|
+
claimed[msg.sender] = true;
|
|
87
|
+
totalClaimed += amount;
|
|
88
|
+
|
|
89
|
+
(bool ok,) = msg.sender.call{value: amount}("");
|
|
90
|
+
if (!ok) revert TransferFailed();
|
|
91
|
+
|
|
92
|
+
emit Claimed(msg.sender, amount);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Sweep ────────────────────────────────────────────────
|
|
96
|
+
/// @notice After claim window closes, sweep remaining balance to sweepRecipient.
|
|
97
|
+
/// Only callable by owner. Locks state to prevent re-sweep.
|
|
98
|
+
function sweep() external {
|
|
99
|
+
if (msg.sender != owner) revert NotOwner();
|
|
100
|
+
if (block.timestamp <= claimDeadline) revert ClaimWindowOpen();
|
|
101
|
+
if (swept) revert AlreadySwept();
|
|
102
|
+
|
|
103
|
+
swept = true;
|
|
104
|
+
|
|
105
|
+
uint256 balance = address(this).balance;
|
|
106
|
+
if (balance > 0) {
|
|
107
|
+
(bool ok,) = sweepRecipient.call{value: balance}("");
|
|
108
|
+
if (!ok) revert TransferFailed();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
emit Swept(sweepRecipient, balance);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── View helpers ─────────────────────────────────────────
|
|
115
|
+
/// @notice Check whether `account` is eligible (proof valid) without claiming.
|
|
116
|
+
function isEligible(address account, uint256 amount, bytes32[] calldata proof)
|
|
117
|
+
external
|
|
118
|
+
view
|
|
119
|
+
returns (bool)
|
|
120
|
+
{
|
|
121
|
+
bytes32 leaf = keccak256(abi.encodePacked(account, amount));
|
|
122
|
+
return _verify(proof, merkleRoot, leaf);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Merkle verification (OpenZeppelin-compatible sorted siblings) ──
|
|
126
|
+
function _verify(bytes32[] calldata proof, bytes32 root, bytes32 leaf)
|
|
127
|
+
private
|
|
128
|
+
pure
|
|
129
|
+
returns (bool)
|
|
130
|
+
{
|
|
131
|
+
bytes32 computed = leaf;
|
|
132
|
+
for (uint256 i = 0; i < proof.length; i++) {
|
|
133
|
+
bytes32 sibling = proof[i];
|
|
134
|
+
if (computed < sibling) {
|
|
135
|
+
computed = keccak256(abi.encodePacked(computed, sibling));
|
|
136
|
+
} else {
|
|
137
|
+
computed = keccak256(abi.encodePacked(sibling, computed));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return computed == root;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.24;
|
|
3
|
+
|
|
4
|
+
/// @title Multicall3
|
|
5
|
+
/// @author Matt Solomon (upstream) - vendored verbatim by Sentrix Labs
|
|
6
|
+
/// @notice Aggregate read/write contract calls into a single tx.
|
|
7
|
+
/// @dev Mirror of github.com/mds1/multicall (canonical address
|
|
8
|
+
/// 0xcA11bde05977b3631167028862bE2a173976CA11 across most chains).
|
|
9
|
+
/// Reproduced here verbatim to keep the canonical-contracts repo
|
|
10
|
+
/// self-contained. License preserved (MIT - owner: Matt Solomon).
|
|
11
|
+
contract Multicall3 {
|
|
12
|
+
struct Call {
|
|
13
|
+
address target;
|
|
14
|
+
bytes callData;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
struct Call3 {
|
|
18
|
+
address target;
|
|
19
|
+
bool allowFailure;
|
|
20
|
+
bytes callData;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
struct Call3Value {
|
|
24
|
+
address target;
|
|
25
|
+
bool allowFailure;
|
|
26
|
+
uint256 value;
|
|
27
|
+
bytes callData;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct Result {
|
|
31
|
+
bool success;
|
|
32
|
+
bytes returnData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// @notice Backwards-compatible aggregate (reverts on any failure).
|
|
36
|
+
function aggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes[] memory returnData) {
|
|
37
|
+
blockNumber = block.number;
|
|
38
|
+
uint256 length = calls.length;
|
|
39
|
+
returnData = new bytes[](length);
|
|
40
|
+
Call calldata call;
|
|
41
|
+
for (uint256 i = 0; i < length; ) {
|
|
42
|
+
bool success;
|
|
43
|
+
call = calls[i];
|
|
44
|
+
(success, returnData[i]) = call.target.call(call.callData);
|
|
45
|
+
require(success, "Multicall3: call failed");
|
|
46
|
+
unchecked { ++i; }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// @notice Aggregate calls; per-call failure mode controllable.
|
|
51
|
+
function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData) {
|
|
52
|
+
uint256 length = calls.length;
|
|
53
|
+
returnData = new Result[](length);
|
|
54
|
+
Call3 calldata calli;
|
|
55
|
+
for (uint256 i = 0; i < length; ) {
|
|
56
|
+
Result memory result = returnData[i];
|
|
57
|
+
calli = calls[i];
|
|
58
|
+
(result.success, result.returnData) = calli.target.call(calli.callData);
|
|
59
|
+
assembly {
|
|
60
|
+
if iszero(or(calldataload(add(calli, 0x20)), mload(result))) {
|
|
61
|
+
mstore(0x00, 0x08c379a0)
|
|
62
|
+
mstore(0x20, 0x20)
|
|
63
|
+
mstore(0x40, 0x17)
|
|
64
|
+
mstore(0x60, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000)
|
|
65
|
+
revert(0x1c, 0x64)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
unchecked { ++i; }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// @notice Aggregate calls with values; per-call failure mode controllable.
|
|
73
|
+
function aggregate3Value(Call3Value[] calldata calls) external payable returns (Result[] memory returnData) {
|
|
74
|
+
uint256 valAccumulator;
|
|
75
|
+
uint256 length = calls.length;
|
|
76
|
+
returnData = new Result[](length);
|
|
77
|
+
Call3Value calldata calli;
|
|
78
|
+
for (uint256 i = 0; i < length; ) {
|
|
79
|
+
Result memory result = returnData[i];
|
|
80
|
+
calli = calls[i];
|
|
81
|
+
uint256 val = calli.value;
|
|
82
|
+
unchecked { valAccumulator += val; }
|
|
83
|
+
(result.success, result.returnData) = calli.target.call{value: val}(calli.callData);
|
|
84
|
+
assembly {
|
|
85
|
+
if iszero(or(calldataload(add(calli, 0x40)), mload(result))) {
|
|
86
|
+
mstore(0x00, 0x08c379a0)
|
|
87
|
+
mstore(0x20, 0x20)
|
|
88
|
+
mstore(0x40, 0x17)
|
|
89
|
+
mstore(0x60, 0x4d756c746963616c6c333a2063616c6c206661696c6564000000000000000000)
|
|
90
|
+
revert(0x1c, 0x64)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
unchecked { ++i; }
|
|
94
|
+
}
|
|
95
|
+
require(msg.value == valAccumulator, "Multicall3: value mismatch");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function blockAndAggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes32 blockHash, Result[] memory returnData) {
|
|
99
|
+
(blockNumber, blockHash, returnData) = tryBlockAndAggregate(calls);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tryBlockAndAggregate(Call[] calldata calls) public payable returns (uint256 blockNumber, bytes32 blockHash, Result[] memory returnData) {
|
|
103
|
+
blockNumber = block.number;
|
|
104
|
+
blockHash = blockhash(block.number);
|
|
105
|
+
uint256 length = calls.length;
|
|
106
|
+
returnData = new Result[](length);
|
|
107
|
+
Call calldata call;
|
|
108
|
+
for (uint256 i = 0; i < length; ) {
|
|
109
|
+
Result memory result = returnData[i];
|
|
110
|
+
call = calls[i];
|
|
111
|
+
(result.success, result.returnData) = call.target.call(call.callData);
|
|
112
|
+
unchecked { ++i; }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getBlockNumber() external view returns (uint256) { return block.number; }
|
|
117
|
+
function getBlockHash(uint256 blockNumber) external view returns (bytes32) { return blockhash(blockNumber); }
|
|
118
|
+
function getCurrentBlockTimestamp() external view returns (uint256) { return block.timestamp; }
|
|
119
|
+
function getCurrentBlockGasLimit() external view returns (uint256) { return block.gaslimit; }
|
|
120
|
+
function getCurrentBlockCoinbase() external view returns (address) { return block.coinbase; }
|
|
121
|
+
function getEthBalance(address addr) external view returns (uint256) { return addr.balance; }
|
|
122
|
+
function getLastBlockHash() external view returns (bytes32) { return blockhash(block.number - 1); }
|
|
123
|
+
function getChainId() external view returns (uint256) { return block.chainid; }
|
|
124
|
+
function getBasefee() external view returns (uint256) { return block.basefee; }
|
|
125
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
pragma solidity 0.8.24;
|
|
3
|
+
|
|
4
|
+
/// @title SentrixSafe
|
|
5
|
+
/// @author Sentrix Labs
|
|
6
|
+
/// @notice Minimal multi-signature wallet for treasury management.
|
|
7
|
+
/// @dev Inspired by Gnosis Safe v1.4.1 but trimmed to the
|
|
8
|
+
/// execute-from-N-of-M-owners core. No modules, no guards,
|
|
9
|
+
/// no fallback handlers - those are out of scope for canonical
|
|
10
|
+
/// treasury use. Owners sign tx hashes off-chain (EIP-712 typed
|
|
11
|
+
/// hashing); on-chain `execTransaction` verifies threshold + nonce.
|
|
12
|
+
///
|
|
13
|
+
/// SECURITY (audit 2026-05-07 v1.1):
|
|
14
|
+
/// - C1: delegatecall path now properly copies calldata into memory before
|
|
15
|
+
/// the DELEGATECALL opcode. Pre-fix the assembly used `data.offset`
|
|
16
|
+
/// as a memory pointer, but DELEGATECALL reads from MEMORY not
|
|
17
|
+
/// CALLDATA — so owners signed one payload and the contract executed
|
|
18
|
+
/// arbitrary memory bytes (or reverted). FIXED.
|
|
19
|
+
/// - C2: full Safe.t.sol test suite added covering execTransaction,
|
|
20
|
+
/// signature verification, replay, delegatecall, owner management.
|
|
21
|
+
/// - H1: ExecutionFailure now reverts (instead of silently succeeding +
|
|
22
|
+
/// emitting an event) so callers can't be fooled into thinking a
|
|
23
|
+
/// failed treasury op succeeded.
|
|
24
|
+
/// - H2: DOMAIN_SEPARATOR rebuilds on chainid mismatch (replay-safe across
|
|
25
|
+
/// hard forks of the host chain).
|
|
26
|
+
/// - H3: removeOwner enforces threshold ≤ remaining-owners floor.
|
|
27
|
+
contract SentrixSafe {
|
|
28
|
+
// ── Storage ──────────────────────────────────────────────
|
|
29
|
+
address[] public owners;
|
|
30
|
+
mapping(address => bool) public isOwner;
|
|
31
|
+
uint256 public threshold;
|
|
32
|
+
uint256 public nonce;
|
|
33
|
+
|
|
34
|
+
// EIP-712 domain separator
|
|
35
|
+
/// @dev Audit H2: cached at construction; if `block.chainid` ever differs
|
|
36
|
+
/// (i.e. the host chain hard-forks the chainid) we recompute on the
|
|
37
|
+
/// fly. The immutable cache stays as the fast path for the 99.99% case.
|
|
38
|
+
bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
|
|
39
|
+
uint256 private immutable _CACHED_CHAIN_ID;
|
|
40
|
+
|
|
41
|
+
bytes32 private constant TX_TYPEHASH = keccak256(
|
|
42
|
+
"SafeTx(address to,uint256 value,bytes data,uint256 operation,uint256 nonce)"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// ── Events ───────────────────────────────────────────────
|
|
46
|
+
event ExecutionSuccess(bytes32 indexed txHash, uint256 nonce);
|
|
47
|
+
event ExecutionFailure(bytes32 indexed txHash, uint256 nonce);
|
|
48
|
+
event AddedOwner(address indexed owner);
|
|
49
|
+
event RemovedOwner(address indexed owner);
|
|
50
|
+
event ChangedThreshold(uint256 threshold);
|
|
51
|
+
|
|
52
|
+
// ── Constructor ──────────────────────────────────────────
|
|
53
|
+
constructor(address[] memory _owners, uint256 _threshold) {
|
|
54
|
+
require(_owners.length > 0, "Safe: no owners");
|
|
55
|
+
require(_threshold > 0 && _threshold <= _owners.length, "Safe: invalid threshold");
|
|
56
|
+
|
|
57
|
+
for (uint256 i = 0; i < _owners.length; i++) {
|
|
58
|
+
address owner = _owners[i];
|
|
59
|
+
require(owner != address(0) && owner != address(this), "Safe: invalid owner");
|
|
60
|
+
require(!isOwner[owner], "Safe: duplicate owner");
|
|
61
|
+
isOwner[owner] = true;
|
|
62
|
+
owners.push(owner);
|
|
63
|
+
emit AddedOwner(owner);
|
|
64
|
+
}
|
|
65
|
+
threshold = _threshold;
|
|
66
|
+
emit ChangedThreshold(_threshold);
|
|
67
|
+
|
|
68
|
+
_CACHED_CHAIN_ID = block.chainid;
|
|
69
|
+
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(block.chainid);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// @dev Build the EIP-712 domain separator for a given chain id.
|
|
73
|
+
/// Audit H2: previously the constructor-built separator was
|
|
74
|
+
/// cached as `immutable`; if the host chain ever changes its
|
|
75
|
+
/// chainid (hard fork or re-org) the cached value would be
|
|
76
|
+
/// stale and signatures would fail to verify even with the
|
|
77
|
+
/// same (chainId, verifyingContract) intent. We rebuild on
|
|
78
|
+
/// mismatch.
|
|
79
|
+
function _buildDomainSeparator(uint256 chainId) private view returns (bytes32) {
|
|
80
|
+
return keccak256(
|
|
81
|
+
abi.encode(
|
|
82
|
+
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
|
|
83
|
+
keccak256(bytes("SentrixSafe")),
|
|
84
|
+
keccak256(bytes("1.1")),
|
|
85
|
+
chainId,
|
|
86
|
+
address(this)
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// @notice Public domain separator, recomputed if host chainid drifted.
|
|
92
|
+
/// @dev Audit H2 fix.
|
|
93
|
+
function DOMAIN_SEPARATOR() public view returns (bytes32) {
|
|
94
|
+
if (block.chainid == _CACHED_CHAIN_ID) {
|
|
95
|
+
return _CACHED_DOMAIN_SEPARATOR;
|
|
96
|
+
}
|
|
97
|
+
return _buildDomainSeparator(block.chainid);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Core: execute with N-of-M signatures ─────────────────
|
|
101
|
+
/// @notice Execute a transaction with `threshold` signatures.
|
|
102
|
+
/// @param to Target address.
|
|
103
|
+
/// @param value Native value (wei) to send.
|
|
104
|
+
/// @param data Calldata.
|
|
105
|
+
/// @param operation 0 = call, 1 = delegatecall.
|
|
106
|
+
/// @param signatures Concatenated 65-byte ECDSA signatures, sorted by signer address ascending.
|
|
107
|
+
function execTransaction(
|
|
108
|
+
address to,
|
|
109
|
+
uint256 value,
|
|
110
|
+
bytes calldata data,
|
|
111
|
+
uint256 operation,
|
|
112
|
+
bytes calldata signatures
|
|
113
|
+
) external returns (bool success) {
|
|
114
|
+
bytes32 txHash = getTransactionHash(to, value, data, operation, nonce);
|
|
115
|
+
checkSignatures(txHash, signatures);
|
|
116
|
+
nonce++;
|
|
117
|
+
|
|
118
|
+
if (operation == 1) {
|
|
119
|
+
// Audit C1 (CRITICAL, 2026-05-07): pre-fix this assembly used
|
|
120
|
+
// `data.offset` (a CALLDATA offset) directly as the DELEGATECALL
|
|
121
|
+
// argsOffset argument, but the DELEGATECALL opcode reads from
|
|
122
|
+
// MEMORY. Owners signed one payload, the contract executed
|
|
123
|
+
// arbitrary memory bytes — or reverted. FIX: copy calldata to
|
|
124
|
+
// a fresh memory region first, then DELEGATECALL points at THAT.
|
|
125
|
+
//
|
|
126
|
+
// We use Solidity's standard pattern (`bytes memory`) which
|
|
127
|
+
// does the calldatacopy + memory layout for free.
|
|
128
|
+
bytes memory dataMem = data;
|
|
129
|
+
assembly {
|
|
130
|
+
// dataMem layout: [length (32 bytes)][bytes...]
|
|
131
|
+
// argsOffset = dataMem + 32 (skip length prefix)
|
|
132
|
+
// argsLength = mload(dataMem)
|
|
133
|
+
let dataPtr := add(dataMem, 0x20)
|
|
134
|
+
let dataLen := mload(dataMem)
|
|
135
|
+
success := delegatecall(gas(), to, dataPtr, dataLen, 0, 0)
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
(success, ) = to.call{value: value}(data);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (success) {
|
|
142
|
+
emit ExecutionSuccess(txHash, nonce - 1);
|
|
143
|
+
} else {
|
|
144
|
+
// Audit H1 (HIGH): pre-fix this only emitted ExecutionFailure +
|
|
145
|
+
// returned `false`, so callers using the standard "(bool success
|
|
146
|
+
// = safe.execTransaction(...))" pattern saw `success = false`
|
|
147
|
+
// but no revert. A treasury op that failed silently would still
|
|
148
|
+
// count as "executed" in audit logs — confusing at best.
|
|
149
|
+
// Now revert with the inner-call's revert reason bubbled up.
|
|
150
|
+
emit ExecutionFailure(txHash, nonce - 1);
|
|
151
|
+
assembly {
|
|
152
|
+
let returnSize := returndatasize()
|
|
153
|
+
returndatacopy(0, 0, returnSize)
|
|
154
|
+
revert(0, returnSize)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Signature verification ───────────────────────────────
|
|
160
|
+
function checkSignatures(bytes32 dataHash, bytes calldata signatures) public view {
|
|
161
|
+
uint256 _threshold = threshold;
|
|
162
|
+
require(signatures.length >= _threshold * 65, "Safe: signatures too short");
|
|
163
|
+
|
|
164
|
+
address lastOwner = address(0);
|
|
165
|
+
for (uint256 i = 0; i < _threshold; i++) {
|
|
166
|
+
address currentOwner = recoverSigner(dataHash, signatures, i);
|
|
167
|
+
require(currentOwner > lastOwner, "Safe: signatures not sorted");
|
|
168
|
+
require(isOwner[currentOwner], "Safe: signer not owner");
|
|
169
|
+
lastOwner = currentOwner;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function recoverSigner(bytes32 dataHash, bytes calldata signatures, uint256 pos) internal pure returns (address) {
|
|
174
|
+
uint8 v;
|
|
175
|
+
bytes32 r;
|
|
176
|
+
bytes32 s;
|
|
177
|
+
assembly {
|
|
178
|
+
let sigPtr := add(signatures.offset, mul(pos, 65))
|
|
179
|
+
r := calldataload(sigPtr)
|
|
180
|
+
s := calldataload(add(sigPtr, 32))
|
|
181
|
+
v := byte(0, calldataload(add(sigPtr, 64)))
|
|
182
|
+
}
|
|
183
|
+
return ecrecover(dataHash, v, r, s);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getTransactionHash(
|
|
187
|
+
address to,
|
|
188
|
+
uint256 value,
|
|
189
|
+
bytes calldata data,
|
|
190
|
+
uint256 operation,
|
|
191
|
+
uint256 _nonce
|
|
192
|
+
) public view returns (bytes32) {
|
|
193
|
+
bytes32 structHash = keccak256(abi.encode(
|
|
194
|
+
TX_TYPEHASH,
|
|
195
|
+
to,
|
|
196
|
+
value,
|
|
197
|
+
keccak256(data),
|
|
198
|
+
operation,
|
|
199
|
+
_nonce
|
|
200
|
+
));
|
|
201
|
+
return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Receive native SRX ──────────────────────────────────
|
|
205
|
+
receive() external payable {}
|
|
206
|
+
|
|
207
|
+
// ── Owner management (self-call only via execTransaction) ─
|
|
208
|
+
function addOwner(address owner, uint256 _threshold) external {
|
|
209
|
+
require(msg.sender == address(this), "Safe: self-call only");
|
|
210
|
+
require(owner != address(0) && !isOwner[owner], "Safe: invalid or existing owner");
|
|
211
|
+
isOwner[owner] = true;
|
|
212
|
+
owners.push(owner);
|
|
213
|
+
emit AddedOwner(owner);
|
|
214
|
+
if (_threshold != threshold) {
|
|
215
|
+
require(_threshold > 0 && _threshold <= owners.length, "Safe: invalid threshold");
|
|
216
|
+
threshold = _threshold;
|
|
217
|
+
emit ChangedThreshold(_threshold);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function removeOwner(address owner, uint256 _threshold) external {
|
|
222
|
+
require(msg.sender == address(this), "Safe: self-call only");
|
|
223
|
+
require(isOwner[owner], "Safe: not owner");
|
|
224
|
+
// Audit H3 (HIGH): minimum-owner floor. Without this, a 1-of-N safe
|
|
225
|
+
// could remove its only remaining owner and brick itself, OR a
|
|
226
|
+
// 2-of-2 safe could remove an owner without lowering threshold and
|
|
227
|
+
// brick itself (threshold > owners.length means no signature set
|
|
228
|
+
// can verify). require(_threshold <= owners.length - 1) covers it.
|
|
229
|
+
require(owners.length > 1, "Safe: cannot remove last owner");
|
|
230
|
+
require(_threshold > 0, "Safe: invalid threshold");
|
|
231
|
+
require(_threshold <= owners.length - 1, "Safe: threshold too high");
|
|
232
|
+
|
|
233
|
+
isOwner[owner] = false;
|
|
234
|
+
for (uint256 i = 0; i < owners.length; i++) {
|
|
235
|
+
if (owners[i] == owner) {
|
|
236
|
+
owners[i] = owners[owners.length - 1];
|
|
237
|
+
owners.pop();
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
emit RemovedOwner(owner);
|
|
242
|
+
|
|
243
|
+
if (_threshold != threshold) {
|
|
244
|
+
threshold = _threshold;
|
|
245
|
+
emit ChangedThreshold(_threshold);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getOwners() external view returns (address[] memory) {
|
|
250
|
+
return owners;
|
|
251
|
+
}
|
|
252
|
+
}
|