Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
.dollar
.idea
build
cache
node_modules
out
coverage.out
coverage.html
go.work
Expand Down
2,040 changes: 2,040 additions & 0 deletions contracts/bun.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions contracts/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[profile.default]
src = "src"
out = "out"
libs = ["node_modules"]
auto_detect_remappings = false
remappings = ["forge-std=node_modules/forge-std/src", "@hyperlane=node_modules/@hyperlane-xyz/core/contracts", "@openzeppelin=node_modules/@openzeppelin"]

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
13 changes: 13 additions & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@noble-assets/usdn",
"private": true,
"devDependencies": {
"@hyperlane-xyz/core": "8.0.0",
"@openzeppelin/contracts": "5.3.0",
"@openzeppelin/contracts-upgradeable": "5.3.0",
"forge-std": "github:foundry-rs/forge-std#v1.9.7"
},
"patchedDependencies": {
"@hyperlane-xyz/core@8.0.0": "patches/@hyperlane-xyz%2Fcore@8.0.0.patch"
}
Comment thread
johnletey marked this conversation as resolved.
}
67 changes: 67 additions & 0 deletions contracts/patches/@hyperlane-xyz%2Fcore@8.0.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
diff --git a/contracts/client/MailboxClient.sol b/contracts/client/MailboxClient.sol
index 0b0d3642db1f2f8a2434f0a1e96438c8caf07011..12e8421805e7ff7e1f0214894a2eae02653691a9 100644
--- a/contracts/client/MailboxClient.sol
+++ b/contracts/client/MailboxClient.sol
@@ -21,7 +21,6 @@ import {Message} from "../libs/Message.sol";
import {PackageVersioned} from "../PackageVersioned.sol";

// ============ External Imports ============
-import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

abstract contract MailboxClient is OwnableUpgradeable, PackageVersioned {
@@ -43,7 +42,7 @@ abstract contract MailboxClient is OwnableUpgradeable, PackageVersioned {
// ============ Modifiers ============
modifier onlyContract(address _contract) {
require(
- Address.isContract(_contract),
+ _contract.code.length > 0,
"MailboxClient: invalid mailbox"
);
_;
@@ -51,7 +50,7 @@ abstract contract MailboxClient is OwnableUpgradeable, PackageVersioned {

modifier onlyContractOrNull(address _contract) {
require(
- Address.isContract(_contract) || _contract == address(0),
+ _contract.code.length > 0 || _contract == address(0),
"MailboxClient: invalid contract setting"
);
_;
@@ -111,10 +110,9 @@ abstract contract MailboxClient is OwnableUpgradeable, PackageVersioned {
address __interchainSecurityModule,
address _owner
) internal onlyInitializing {
- __Ownable_init();
+ __Ownable_init(_owner);
setHook(_hook);
setInterchainSecurityModule(__interchainSecurityModule);
- _transferOwnership(_owner);
}

function _isLatestDispatched(bytes32 id) internal view returns (bool) {
diff --git a/contracts/token/HypERC20.sol b/contracts/token/HypERC20.sol
index 6463193893c8c2d195cf24052ee71b12e76a61a0..8af0c875fedfe160de9c4dcaaccc30897750536e 100644
--- a/contracts/token/HypERC20.sol
+++ b/contracts/token/HypERC20.sol
@@ -25,12 +25,10 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter {

/**
* @notice Initializes the Hyperlane router, ERC20 metadata, and mints initial supply to deployer.
- * @param _totalSupply The initial supply of the token.
* @param _name The name of the token.
* @param _symbol The symbol of the token.
*/
function initialize(
- uint256 _totalSupply,
string memory _name,
string memory _symbol,
address _hook,
@@ -39,7 +37,6 @@ contract HypERC20 is ERC20Upgradeable, FungibleTokenRouter {
) public virtual initializer {
// Initialize ERC20 metadata
__ERC20_init(_name, _symbol);
- _mint(msg.sender, _totalSupply);
_MailboxClient_initialize(_hook, _interchainSecurityModule, _owner);
}

214 changes: 214 additions & 0 deletions contracts/src/NobleDollar.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/*
* Copyright 2025 NASD Inc. All rights reserved.
*
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
pragma solidity >=0.8.0;
Comment thread
johnletey marked this conversation as resolved.
Outdated

import {HypERC20} from "@hyperlane/token/HypERC20.sol";

/*

███╗ ██╗ ██████╗ ██████╗ ██╗ ███████╗
████╗ ██║██╔═══██╗██╔══██╗██║ ██╔════╝
██╔██╗ ██║██║ ██║██████╔╝██║ █████╗
██║╚██╗██║██║ ██║██╔══██╗██║ ██╔══╝
██║ ╚████║╚██████╔╝██████╔╝███████╗███████╗
╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝

██████╗ ██████╗ ██╗ ██╗ █████╗ ██████╗
██╔══██╗██╔═══██╗██║ ██║ ██╔══██╗██╔══██╗
██║ ██║██║ ██║██║ ██║ ███████║██████╔╝
██║ ██║██║ ██║██║ ██║ ██╔══██║██╔══██╗
██████╔╝╚██████╔╝███████╗███████╗██║ ██║██║ ██║
╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝

*/

/**
* @title NobleDollar
* @author John Letey <john@noble.xyz>
* @notice ERC20 Noble Dollar.
*/
contract NobleDollar is HypERC20 {
/// @notice Thrown when a user attempts to claim yield but has no claimable yield available.
error NoClaimableYield();

/// @notice Thrown when an invalid transfer to the contract is attempted.
error InvalidTransfer();

/**
* @notice Emitted when the index is updated due to yield accrual.
* @param oldIndex The previous index value.
* @param newIndex The new index value.
* @param totalPrincipal The total principal amount at the time of update.
* @param yieldAccrued The amount of yield that was accrued.
*/
event IndexUpdated(uint128 oldIndex, uint128 newIndex, uint256 totalPrincipal, uint256 yieldAccrued);

/**
* @notice Emitted when yield is claimed by an account.
* @param account The account that claimed the yield.
* @param amount The amount of yield claimed.
*/
event YieldClaimed(address indexed account, uint256 amount);

/// @custom:storage-location erc7201:noble.storage.USDN
struct USDNStorage {
uint128 index;
mapping(address account => uint256) principal;
uint256 totalPrincipal;
}

// keccak256(abi.encode(uint256(keccak256("noble.storage.USDN")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant USDNStorageLocation = 0xccec1a0a356b34ea3899fbc248aeaeba5687659563a3acddccc6f1e8a5d84200;

function _getUSDNStorage() private pure returns (USDNStorage storage $) {
assembly {
$.slot := USDNStorageLocation
}
}

constructor(address mailbox_) HypERC20(6, 1, mailbox_) {}

function initialize(address hook_, address ism_) public virtual initializer {
super.initialize("Noble Dollar", "USDN", hook_, ism_, msg.sender);

USDNStorage storage $ = _getUSDNStorage();
$.index = 1e12;
}

/// @dev Returns the current index used for yield calculations.
function index() public view returns (uint256) {
return _getUSDNStorage().index;
}

/// @dev Returns the amount of principal in existence.
function totalPrincipal() public view returns (uint256) {
return _getUSDNStorage().totalPrincipal;
}

/// @dev Returns the amount of principal owned for a given account.
function principalOf(address account) public view returns (uint256) {
return _getUSDNStorage().principal[account];
}

/**
* @notice Returns the amount of yield claimable for a given account.
* @dev Calculates claimable yield by comparing the expected balance (principal * current index)
* with the actual token balance. The yield represents the difference between what the
* account should have based on yield accrual and what they currently hold.
*
* Formula: max(0, (principal * index / 1e12) - currentBalance)
*
* Returns 0 if the current balance is greater than or equal to the expected balance,
* which can happen if the account has already claimed their yield or if no yield
* has accrued since their last interaction.
*
* @param account The address to check yield for.
* @return The amount of yield claimable by the account.
*/
function yield(address account) public view returns (uint256) {
USDNStorage storage $ = _getUSDNStorage();
uint256 expectedBalance = $.principal[account] * $.index / 1e12;
uint256 currentBalance = balanceOf(account);

return expectedBalance > currentBalance ? expectedBalance - currentBalance : 0;
}

/**
* @notice Claims all available yield for the caller.
* @dev Calculates the claimable yield based on the difference between the expected balance
* (principal * current index) and the actual token balance. Transfers the yield amount
* from the contract to the caller and emits a YieldClaimed event.
* @custom:throws NoClaimableYield if the caller has no yield available to claim.
* @custom:emits YieldClaimed when yield is successfully claimed.
*/
function claim() public {
uint256 amount = yield(msg.sender);
if (amount == 0) {
revert NoClaimableYield();
}

_transfer(address(this), msg.sender, amount);

emit YieldClaimed(msg.sender, amount);
}

/**
* @notice Internal function that handles token transfers while managing principal accounting.
* @dev Overrides the base ERC20 _update function to implement yield-bearing token mechanics.
* This function manages principal balances and index updates for different transfer scenarios:
*
* 1. Yield payout (from contract): No principal updates needed
* 2. Yield accrual (to contract from zero address): Updates index based on new yield
* 3. Regular transfers: Updates principal balances for both sender and recipient
* 4. Minting (from zero address): Increases recipient's principal and total principal
* 5. Burning (to zero address): Decreases sender's principal and total principal
*
* @param from The address tokens are transferred from (zero address for minting)
* @param to The address tokens are transferred to (zero address for burning)
* @param value The amount of tokens being transferred
*
* @custom:throws InvalidTransfer if attempting to transfer to the contract from a non-zero address
* @custom:emits IndexUpdated when yield is accrued and the index is updated
* @custom:security Principal is calculated using ceiling division to prevent rounding errors
*/
function _update(address from, address to, uint256 value) internal virtual override {
USDNStorage storage $ = _getUSDNStorage();

super._update(from, to, value);

if (from == address(this)) {
// We don't want to perform any principal updates in the case of yield payout.
return;
}
if (to == address(this)) {
if (from == address(0)) {
// We don't want to perform any principal updates in the case of yield accrual.
uint128 oldIndex = $.index;

$.index = uint128(totalSupply() * 1e12 / $.totalPrincipal);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

emit IndexUpdated(oldIndex, $.index, $.totalPrincipal, value);

return;
}

// We don't want to allow any other transfers to the yield account.
revert InvalidTransfer();
}

uint256 principal = ((value * 1e12) + $.index - 1) / $.index;

// We don't want to update the sender's principal in the case of issuance.
if (from != address(0)) {
$.principal[from] -= principal;
} else {
$.totalPrincipal += principal;
}

// We don't want to update the recipient's principal in the case of withdrawal.
if (to != address(0)) {
if (from == address(0)) {
principal = (value * 1e12) / $.index;
}

$.principal[to] += principal;
} else {
$.totalPrincipal -= principal;
}
}
}
Loading
Loading