Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .changeset/few-parks-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hashgraph/asset-tokenization-contracts": minor
"@hashgraph/asset-tokenization-sdk": minor
"@hashgraph/asset-tokenization-dapp": patch
---

full redeem at maturity method added
25 changes: 24 additions & 1 deletion packages/ats/contracts/contracts/layer_2/bond/Bond.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ import { Common } from "../../layer_1/common/Common.sol";
import { _CORPORATE_ACTION_ROLE, _BOND_MANAGER_ROLE, _MATURITY_REDEEMER_ROLE } from "../../layer_1/constants/roles.sol";

abstract contract Bond is IBond, Common {
function fullRedeemAtMaturity(
address _tokenHolder
)
external
override
onlyUnpaused
validateAddress(_tokenHolder)
onlyListedAllowed(_tokenHolder)
onlyRole(_MATURITY_REDEEMER_ROLE)
onlyClearingDisabled
onlyValidKycStatus(IKyc.KycStatus.GRANTED, _tokenHolder)
onlyUnrecoveredAddress(_tokenHolder)
onlyAfterCurrentMaturityDate(_blockTimestamp())
{
bytes32[] memory partitions = _partitionsOf(_tokenHolder);
for (uint256 i = 0; i < partitions.length; i++) {
bytes32 partition = partitions[i];
uint256 balance = _balanceOfByPartition(partition, _tokenHolder);
if (balance > 0) {
_redeemByPartition(partition, _tokenHolder, _msgSender(), balance, "", "");
}
}
}

function redeemAtMaturityByPartition(
address _tokenHolder,
bytes32 _partition,
Expand All @@ -21,7 +45,6 @@ abstract contract Bond is IBond, Common {
onlyListedAllowed(_tokenHolder)
onlyRole(_MATURITY_REDEEMER_ROLE)
onlyClearingDisabled
onlyUnProtectedPartitionsOrWildCardRole
onlyValidKycStatus(IKyc.KycStatus.GRANTED, _tokenHolder)
onlyUnrecoveredAddress(_tokenHolder)
onlyAfterCurrentMaturityDate(_blockTimestamp())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ pragma solidity >=0.8.0 <0.9.0;

import { IBondRead } from "./IBondRead.sol";
interface IBond {
/**
* @notice Redeems all bonds at maturity from a token holder (all partitions considered)
* @param _tokenHolder The address of the token holder redeeming the bonds.
*/
function fullRedeemAtMaturity(address _tokenHolder) external;

/**
* @notice Redeems a specified amount of bonds at maturity from a token holder from a specific partition
* @param _tokenHolder The address of the token holder redeeming the bonds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ contract BondUSAFacet is BondUSA, IStaticFunctionSelectors {

function getStaticFunctionSelectors() external pure override returns (bytes4[] memory staticFunctionSelectors_) {
uint256 selectorIndex;
staticFunctionSelectors_ = new bytes4[](4);
staticFunctionSelectors_ = new bytes4[](5);
staticFunctionSelectors_[selectorIndex++] = this._initialize_bondUSA.selector;
staticFunctionSelectors_[selectorIndex++] = this.setCoupon.selector;
staticFunctionSelectors_[selectorIndex++] = this.updateMaturityDate.selector;
staticFunctionSelectors_[selectorIndex++] = this.redeemAtMaturityByPartition.selector;
staticFunctionSelectors_[selectorIndex++] = this.fullRedeemAtMaturity.selector;
}

function getStaticInterfaceIds() external pure override returns (bytes4[] memory staticInterfaceIds_) {
Expand Down
3 changes: 2 additions & 1 deletion packages/ats/contracts/scripts/domain/atsRegistry.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*
* Import from '@scripts/domain' instead of this file directly.
*
* Generated: 2025-11-17T14:14:52.392Z
* Generated: 2025-11-18T13:26:55.400Z
* Facets: 49
* Infrastructure: 2
*
Expand Down Expand Up @@ -200,6 +200,7 @@ export const FACET_REGISTRY: Record<string, FacetDefinition> = {
signature: "_initialize_bondUSA(IBondRead.BondDetailsData,RegulationData,AdditionalSecurityData)",
selector: "0x653458ea",
},
{ name: "fullRedeemAtMaturity", signature: "fullRedeemAtMaturity(address)", selector: "0xd0db5fb2" },
{
name: "redeemAtMaturityByPartition",
signature: "redeemAtMaturityByPartition(address,bytes32,uint256)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ClearingTransferFacet,
BondUSAReadFacet,
TimeTravelFacet as TimeTravel,
IERC3643,
} from "@contract-types";
import {
DEFAULT_PARTITION,
Expand Down Expand Up @@ -82,6 +83,7 @@ describe("Bond Tests", () => {
let protectedPartitionsFacet: ProtectedPartitions;
let freezeFacet: FreezeFacet;
let clearingTransferFacet: ClearingTransferFacet;
let erc3643Facet: IERC3643;

async function deploySecurityFixture(isMultiPartition = false) {
const base = await deployBondTokenFixture({
Expand Down Expand Up @@ -134,6 +136,10 @@ describe("Bond Tests", () => {
role: ATS_ROLES._PROTECTED_PARTITIONS_ROLE,
members: [signer_A.address],
},
{
role: ATS_ROLES._AGENT_ROLE,
members: [signer_A.address],
},
]);

bondFacet = await ethers.getContractAt("BondUSAFacetTimeTravel", diamond.address, signer_A);
Expand All @@ -147,6 +153,7 @@ describe("Bond Tests", () => {
timeTravelFacet = await ethers.getContractAt("TimeTravelFacet", diamond.address, signer_A);
kycFacet = await ethers.getContractAt("Kyc", diamond.address, signer_B);
ssiManagementFacet = await ethers.getContractAt("SsiManagement", diamond.address, signer_A);
erc3643Facet = await ethers.getContractAt("IERC3643", diamond.address);

await ssiManagementFacet.connect(signer_A).addIssuer(signer_A.address);

Expand Down Expand Up @@ -197,6 +204,11 @@ describe("Bond Tests", () => {
await expect(
bondFacet.redeemAtMaturityByPartition(ADDRESS_ZERO, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "ZeroAddressNotAllowed");

await expect(bondFacet.fullRedeemAtMaturity(ADDRESS_ZERO)).to.be.revertedWithCustomError(
bondFacet,
"ZeroAddressNotAllowed",
);
});

it("GIVEN single partition mode WHEN redeeming from a non-default partition THEN transaction fails with PartitionNotAllowedInSinglePartitionMode", async () => {
Expand All @@ -211,27 +223,34 @@ describe("Bond Tests", () => {
await expect(
bondFacet.connect(signer_A).redeemAtMaturityByPartition(signer_B.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "AccountIsBlocked");

await expect(bondFacet.connect(signer_A).fullRedeemAtMaturity(signer_B.address)).to.be.revertedWithCustomError(
bondFacet,
"AccountIsBlocked",
);
});

it("GIVEN the caller lacks the Maturity Redeemer role WHEN redeeming at maturity THEN transaction fails with AccountHasNoRole", async () => {
await expect(
bondFacet.connect(signer_B).redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "AccountHasNoRole");

await expect(bondFacet.connect(signer_B).fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
bondFacet,
"AccountHasNoRole",
);
});
it("GIVEN clearing is activated WHEN redeeming at maturity THEN transaction fails with ClearingIsActivated", async () => {
await clearingActionsFacet.activateClearing();

await expect(
bondFacet.redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "ClearingIsActivated");
});

it("GIVEN partitions are protected AND caller lacks required role WHEN redeeming at maturity THEN transaction fails with PartitionsAreProtectedAndNoRole", async () => {
await protectedPartitionsFacet.protectPartitions();

await expect(
bondFacet.redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "PartitionsAreProtectedAndNoRole");
await expect(bondFacet.fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
bondFacet,
"ClearingIsActivated",
);
});

it("GIVEN the token is paused WHEN redeeming at maturity THEN transaction fails with TokenIsPaused", async () => {
Expand All @@ -247,19 +266,48 @@ describe("Bond Tests", () => {
await expect(
bondFacet.connect(signer_C).redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "TokenIsPaused");

await expect(bondFacet.connect(signer_C).fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
bondFacet,
"TokenIsPaused",
);
});

it("GIVEN the token holder lacks valid KYC status WHEN redeeming at maturity THEN transaction fails with InvalidKycStatus", async () => {
await expect(
bondFacet.redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "InvalidKycStatus");

await expect(bondFacet.fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
bondFacet,
"InvalidKycStatus",
);
});

it("GIVEN the current date is before maturity WHEN redeeming at maturity THEN transaction fails with BondMaturityDateWrong", async () => {
await expect(
bondFacet.redeemAtMaturityByPartition(signer_A.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "BondMaturityDateWrong");

await expect(bondFacet.fullRedeemAtMaturity(signer_A.address)).to.be.revertedWithCustomError(
bondFacet,
"BondMaturityDateWrong",
);
});

it("GIVEN a recovered wallet WHEN redeeming at maturity THEN transaction fails with WalletRecovered", async () => {
await erc3643Facet.recoveryAddress(signer_A.address, signer_B.address, ADDRESS_ZERO);

await expect(
bondFacet.redeemAtMaturityByPartition(signer_A.address, DEFAULT_PARTITION, amount),
).to.be.revertedWithCustomError(bondFacet, "WalletRecovered");

await expect(bondFacet.fullRedeemAtMaturity(signer_A.address)).to.be.revertedWithCustomError(
bondFacet,
"WalletRecovered",
);
});

it("GIVEN all conditions are met WHEN redeeming at maturity THEN transaction succeeds and emits RedeemedByPartition", async () => {
await accessControlFacet.connect(signer_A).grantRole(ATS_ROLES._ISSUER_ROLE, signer_C.address);

Expand All @@ -276,6 +324,23 @@ describe("Bond Tests", () => {
.to.emit(bondFacet, "RedeemedByPartition")
.withArgs(DEFAULT_PARTITION, signer_A.address, signer_A.address, amount, "0x", "0x");
});

it("GIVEN all conditions are met WHEN redeeming all at maturity THEN transaction succeeds and emits RedeemedByPartition", async () => {
await accessControlFacet.connect(signer_A).grantRole(ATS_ROLES._ISSUER_ROLE, signer_C.address);

await erc1410Facet.connect(signer_C).issueByPartition({
partition: DEFAULT_PARTITION,
tokenHolder: signer_A.address,
value: amount,
data: "0x",
});

await timeTravelFacet.changeSystemTimestamp(maturityDate + 1);

await expect(bondFacet.fullRedeemAtMaturity(signer_A.address))
.to.emit(bondFacet, "RedeemedByPartition")
.withArgs(DEFAULT_PARTITION, signer_A.address, signer_A.address, amount, "0x", "0x");
});
});

describe("Coupons", () => {
Expand Down Expand Up @@ -772,5 +837,30 @@ describe("Bond Tests", () => {
.to.emit(bondFacet, "RedeemedByPartition")
.withArgs(_PARTITION_ID, signer_A.address, signer_A.address, amount, "0x", "0x");
});

it("GIVEN a new diamond contract with multi-partition WHEN redeemAtMaturityByPartition is called THEN transaction success", async () => {
await deploySecurityFixture(true);
await accessControlFacet.connect(signer_A).grantRole(ATS_ROLES._ISSUER_ROLE, signer_C.address);
await erc1410Facet.connect(signer_C).issueByPartition({
partition: _PARTITION_ID,
tokenHolder: signer_A.address,
value: amount,
data: "0x",
});
await erc1410Facet.connect(signer_C).issueByPartition({
partition: DEFAULT_PARTITION,
tokenHolder: signer_A.address,
value: amount,
data: "0x",
});

await timeTravelFacet.changeSystemTimestamp(maturityDate + 1);

await expect(bondFacet.fullRedeemAtMaturity(signer_A.address))
.to.emit(bondFacet, "RedeemedByPartition")
.withArgs(_PARTITION_ID, signer_A.address, signer_A.address, amount, "0x", "0x")
.to.emit(bondFacet, "RedeemedByPartition")
.withArgs(DEFAULT_PARTITION, signer_A.address, signer_A.address, amount, "0x", "0x");
});
});
});
14 changes: 14 additions & 0 deletions packages/ats/sdk/__tests__/fixtures/bond/BondFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import { BigNumber } from 'ethers';
import { Coupon } from '@domain/context/bond/Coupon';
import { RedeemAtMaturityByPartitionCommand } from '@command/bond/redeemAtMaturityByPartition/RedeemAtMaturityByPartitionCommand';
import RedeemAtMaturityByPartitionRequest from '@port/in/request/bond/RedeemAtMaturityByPartitionRequest';
import { FullRedeemAtMaturityCommand } from '@command/bond/fullRedeemAtMaturity/FullRedeemAtMaturityCommand';
import FullRedeemAtMaturityRequest from '@port/in/request/bond/FullRedeemAtMaturityRequest';
import { GetCouponHoldersQuery } from '@query/bond/coupons/getCouponHolders/GetCouponHoldersQuery';
import { GetTotalCouponHoldersQuery } from '@query/bond/coupons/getTotalCouponHolders/GetTotalCouponHoldersQuery';
import GetCouponHoldersRequest from '@port/in/request/bond/GetCouponHoldersRequest';
Expand Down Expand Up @@ -195,6 +197,12 @@ export const RedeemAtMaturityByPartitionCommandFixture =
command.partitionId.as(() => PartitionIdFixture.create().value);
});

export const FullRedeemAtMaturityCommandFixture =
createFixture<FullRedeemAtMaturityCommand>((command) => {
command.securityId.as(() => HederaIdPropsFixture.create().value);
command.sourceId.as(() => HederaIdPropsFixture.create().value);
});

export const BondDetailsFixture = createFixture<BondDetails>((props) => {
props.currency.faker((faker) => faker.finance.currencyCode());
props.nominalValue.faker((faker) =>
Expand Down Expand Up @@ -444,6 +452,12 @@ export const RedeemAtMaturityByPartitionRequestFixture =
request.partitionId.as(() => PartitionIdFixture.create().value);
});

export const FullRedeemAtMaturityRequestFixture =
createFixture<FullRedeemAtMaturityRequest>((request) => {
request.securityId.as(() => HederaIdPropsFixture.create().value);
request.sourceId.as(() => HederaIdPropsFixture.create().value);
});

export const CreateTrexSuiteBondRequestFixture =
createFixture<CreateTrexSuiteBondRequest>((request) => {
request.salt.faker((faker) => faker.string.alphanumeric({ length: 32 }));
Expand Down
Loading