CompliantDepositRegistry
Purpose
CompliantDepositRegistry
assigns one BTC deposit address to each compliant investor address. Deposit addresses are pre-generated off-chain by the custodian and added to the registry in batches that are temporarily challengeable. Investors self-register (idempotent), and the registry enforces a compliance check via an external IComplianceChecker
. This contract is used for registering the BTC Deposit Address for BTC Native Deposits to Mint hBTC.
Imports
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {IComplianceChecker} from "./interfaces/IComplianceChecker.sol";
import {ICompliantDepositRegistry} from "./interfaces/ICompliantDepositRegistry.sol";
AccessControl — role-based permissions for admin, batch creators, and cancelers.
IComplianceChecker — on-chain compliance gate; exposes
isCompliant
/requireCompliant
.ICompliantDepositRegistry — declares the registry’s custom errors/events/interfaces used here.
Roles
bytes32 public constant DEPOSIT_ADDRESS_CREATOR_ROLE = keccak256("DEPOSIT_ADDRESS_CREATOR_ROLE");
bytes32 public constant CANCELER_ROLE = keccak256("CANCELER_ROLE");
DEFAULT_ADMIN_ROLE (AccessControl): can grant/revoke roles; can set the challenge period.
DEPOSIT_ADDRESS_CREATOR_ROLE: may push new batches of addresses.
CANCELER_ROLE: may challenge (cancel) the latest batch during its challenge window.
Storage Layout
mapping(address investor => uint depositAddressIndex) public investorDepositMap;
string[] public depositAddresses; // [0] is a sentinel "Blocked entry"
uint256 public nextDepositAddressIndex; // starts at 1
uint256 public batchChallengePeriod; // seconds
uint256 public latestBatchUnlockTime; // timestamp when challenge window ends
uint256 public finalizedAddressesLength;// array length at the moment the latest batch was added
IComplianceChecker public immutable complianceChecker;
Invariants / Design Notes
Index 0 is blocked by design so that “zero” in
investorDepositMap
unambiguously means not registered.nextDepositAddressIndex
starts at 1.At
addDepositAddresses(...)
, we setfinalizedAddressesLength = depositAddresses.length
(the “start” of the new batch). All addresses with index>= finalizedAddressesLength
form the latest batch and remain challengeable untillatestBatchUnlockTime
.Investors can only be assigned addresses from finalized space unless the challenge window passed for the latest batch.
Constructor
constructor(
address defaultAdmin,
address complianceCheckerAddress,
address cancelerAddress
) {
_grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
_grantRole(CANCELER_ROLE, cancelerAddress);
complianceChecker = IComplianceChecker(complianceCheckerAddress);
depositAddresses.push("Blocked entry");
nextDepositAddressIndex = 1;
_setBatchChallengePeriod(1 days);
}
Sets admin and canceller.
Pins
complianceChecker
.Inserts a sentinel into
depositAddresses[0]
.Defaults
batchChallengePeriod
to 1 day.
Events (emitted)
Event types are declared in
ICompliantDepositRegistry
. Names and payloads below match theemit
sites in your code.
event DepositAddressSet(address indexed investor, string depositAddress);
event NewDepositAddressesAdded(uint256 indexed startIndex, uint256 unlockTime, string[] newDepositAddresses);
event BatchChallengePeriodSet(uint256 newChallengePeriod);
event BatchChallenged(uint256 indexed finalizedStart, uint256 timestamp, uint256 removed);
Errors (thrown)
Declared in ICompliantDepositRegistry
/ IComplianceChecker
and used here:
error UnregisteredInvestor();
error OutOfDepositAddresses();
error NoNewAddressesDuringChallengePeriod();
error NoChallengeAfterUnlock();
error ComplianceCheckFailed();
(bubbled fromcomplianceChecker.requireCompliant
)
Public / External
getDepositAddress
function getDepositAddress(address investor) public view returns (string memory);
What it does
Returns the assigned BTC deposit address for
investor
.
Requirements / Reverts
Reverts
UnregisteredInvestor()
ifinvestorDepositMap[investor] == 0
.Calls
complianceChecker.requireCompliant(investor)
and revertsComplianceCheckFailed()
if not compliant at read time.
Returns
The BTC deposit address string from
depositAddresses[investorDepositIndex]
.
Notes
The compliance check happens on every read to actively block non-compliant users from depositing even if previously registered.
hasRegisteredDepositAddress
function hasRegisteredDepositAddress(address investor) public view returns (bool);
Returns true
iff the investor already has a non-zero index in investorDepositMap
.
registerDepositAddress
function registerDepositAddress() public returns (string memory depositAddress);
What it does
Idempotent investor self-registration.
If the caller already has an index, it returns the existing address (after a compliance check inside
getDepositAddress
).Otherwise, assigns the next available finalized address.
Flow
If
investorDepositMap[msg.sender] > 0
→return getDepositAddress(msg.sender)
.Let
_next = nextDepositAddressIndex
.If
_next >= finalizedAddressesLength
, enforce:_next < depositAddresses.length
ANDlatestBatchUnlockTime < block.timestamp
Else revertOutOfDepositAddresses()
.
complianceChecker.requireCompliant(msg.sender)
.Assign:
investorDepositMap[msg.sender] = _next; nextDepositAddressIndex++
.depositAddress = getDepositAddress(msg.sender);
emit DepositAddressSet(msg.sender, depositAddress)
.
Returns
The assigned BTC deposit address string.
Operational note
Your in-code doc comment recommends waiting for several confirmations off-chain before surfacing the address to the user.
depositAddressesLength
function depositAddressesLength() public view returns (uint);
Returns depositAddresses.length
. (Includes index 0
sentinel.)
getDepositAddresses
function getDepositAddresses(uint256 startIndex, uint256 count)
public view returns (string[] memory addresses);
Reads a page (slice) of addresses from the array.
If the requested count
overshoots, the function trims to available items.
addDepositAddresses
function addDepositAddresses(string[] calldata newDepositAddresses)
public onlyRole(DEPOSIT_ADDRESS_CREATOR_ROLE);
What it does
Appends a new batch of deposit addresses.
Finalizes all previously existing addresses by setting
finalizedAddressesLength = depositAddresses.length
(the pre-append length).Opens a challenge window for the new batch until
block.timestamp + batchChallengePeriod
.
Requirements / Reverts
Reverts
NoNewAddressesDuringChallengePeriod()
if the previous batch is still challengeable (latestBatchUnlockTime >= block.timestamp
).
Effects
Pushes each new address into
depositAddresses
.Sets
latestBatchUnlockTime = block.timestamp + batchChallengePeriod
.Emits:
NewDepositAddressesAdded(startIndex, latestBatchUnlockTime, newDepositAddresses)
where
startIndex
is the pre-append length (== new batch start).
setBatchChallengePeriod
function setBatchChallengePeriod(uint256 newChallengePeriod)
public onlyRole(DEFAULT_ADMIN_ROLE);
Sets the challenge window length (seconds). Emits BatchChallengePeriodSet(newChallengePeriod)
.
challengeLatestBatch (overload #1)
function challengeLatestBatch() public onlyRole(CANCELER_ROLE);
Convenience overload. Computes the size of the latest batch as:
depositAddresses.length - finalizedAddressesLength
and calls the parameterized overload below.
challengeLatestBatch (overload #2)
function challengeLatestBatch(uint256 length) public onlyRole(CANCELER_ROLE);
What it does
Cancels (pops)
length
addresses from the end ofdepositAddresses
, i.e., from the still-challengeable “latest batch”.
Requirements / Reverts
Reverts
NoChallengeAfterUnlock()
if the challenge window has already expired:require(latestBatchUnlockTime >= block.timestamp, NoChallengeAfterUnlock())
(I.e., you can only challenge until
latestBatchUnlockTime
.)
Effects
for (i=0; i<length; i++) depositAddresses.pop();
Sets
latestBatchUnlockTime = block.timestamp;
(resets so a new batch can be added)Emits:
BatchChallenged(finalizedAddressesLength, block.timestamp, length);
Notes
The helper without args challenges the entire latest batch. This overload lets you challenge a subset if you choose.
Access Control Summary
DEFAULT_ADMIN_ROLE
Grant/revoke roles.
setBatchChallengePeriod(...)
.
DEPOSIT_ADDRESS_CREATOR_ROLE
addDepositAddresses(...)
.
CANCELER_ROLE
challengeLatestBatch(...)
.
Access control events (RoleGranted
, RoleRevoked
, RoleAdminChanged
) come from OpenZeppelin’s AccessControl
.
Integration Pattern (Typical)
Custodian/off-chain system generates and verifies a list of BTC addresses.
Address creator calls
addDepositAddresses(batch)
. A challenge window opens.If the batch is valid, do nothing; once the window elapses, those addresses become assignable. If a problem is detected, a canceller calls
challengeLatestBatch()
beforelatestBatchUnlockTime
.Investors call
registerDepositAddress()
. The registry enforces compliance and assigns the next finalized address.Downstream systems use
getDepositAddress(user)
to read the address (compliance-gated at read time).
Security Considerations
Compliance at read time:
getDepositAddress
re-checks compliance so a user that becomes non-compliant cannot fetch the address to deposit.Batch challenge: protects against bad address uploads; only the latest batch is challengeable.
Exhaustion guard:
registerDepositAddress
reverts if no finalized addresses are available (or the last batch is still challengeable).Sentinel index: prevents accidental assignment of an uninitialized (zero) index
Last updated