@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.
@@ -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
+ }