Skip to content

Commit 0a0f427

Browse files
Feat/bbnd 1246 redeem all at maturity (#703)
Signed-off-by: Alberto Molina <[email protected]>
1 parent 2aa458c commit 0a0f427

File tree

19 files changed

+1494
-11
lines changed

19 files changed

+1494
-11
lines changed

.changeset/few-parks-share.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hashgraph/asset-tokenization-contracts": minor
3+
"@hashgraph/asset-tokenization-sdk": minor
4+
"@hashgraph/asset-tokenization-dapp": patch
5+
---
6+
7+
full redeem at maturity method added

packages/ats/contracts/contracts/layer_2/bond/Bond.sol

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,30 @@ import { Common } from "../../layer_1/common/Common.sol";
88
import { _CORPORATE_ACTION_ROLE, _BOND_MANAGER_ROLE, _MATURITY_REDEEMER_ROLE } from "../../layer_1/constants/roles.sol";
99

1010
abstract contract Bond is IBond, Common {
11+
function fullRedeemAtMaturity(
12+
address _tokenHolder
13+
)
14+
external
15+
override
16+
onlyUnpaused
17+
validateAddress(_tokenHolder)
18+
onlyListedAllowed(_tokenHolder)
19+
onlyRole(_MATURITY_REDEEMER_ROLE)
20+
onlyClearingDisabled
21+
onlyValidKycStatus(IKyc.KycStatus.GRANTED, _tokenHolder)
22+
onlyUnrecoveredAddress(_tokenHolder)
23+
onlyAfterCurrentMaturityDate(_blockTimestamp())
24+
{
25+
bytes32[] memory partitions = _partitionsOf(_tokenHolder);
26+
for (uint256 i = 0; i < partitions.length; i++) {
27+
bytes32 partition = partitions[i];
28+
uint256 balance = _balanceOfByPartition(partition, _tokenHolder);
29+
if (balance > 0) {
30+
_redeemByPartition(partition, _tokenHolder, _msgSender(), balance, "", "");
31+
}
32+
}
33+
}
34+
1135
function redeemAtMaturityByPartition(
1236
address _tokenHolder,
1337
bytes32 _partition,
@@ -21,7 +45,6 @@ abstract contract Bond is IBond, Common {
2145
onlyListedAllowed(_tokenHolder)
2246
onlyRole(_MATURITY_REDEEMER_ROLE)
2347
onlyClearingDisabled
24-
onlyUnProtectedPartitionsOrWildCardRole
2548
onlyValidKycStatus(IKyc.KycStatus.GRANTED, _tokenHolder)
2649
onlyUnrecoveredAddress(_tokenHolder)
2750
onlyAfterCurrentMaturityDate(_blockTimestamp())

packages/ats/contracts/contracts/layer_2/interfaces/bond/IBond.sol

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ pragma solidity >=0.8.0 <0.9.0;
33

44
import { IBondRead } from "./IBondRead.sol";
55
interface IBond {
6+
/**
7+
* @notice Redeems all bonds at maturity from a token holder (all partitions considered)
8+
* @param _tokenHolder The address of the token holder redeeming the bonds.
9+
*/
10+
function fullRedeemAtMaturity(address _tokenHolder) external;
11+
612
/**
713
* @notice Redeems a specified amount of bonds at maturity from a token holder from a specific partition
814
* @param _tokenHolder The address of the token holder redeeming the bonds.

packages/ats/contracts/contracts/layer_3/bondUSA/BondUSAFacet.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ contract BondUSAFacet is BondUSA, IStaticFunctionSelectors {
1515

1616
function getStaticFunctionSelectors() external pure override returns (bytes4[] memory staticFunctionSelectors_) {
1717
uint256 selectorIndex;
18-
staticFunctionSelectors_ = new bytes4[](4);
18+
staticFunctionSelectors_ = new bytes4[](5);
1919
staticFunctionSelectors_[selectorIndex++] = this._initialize_bondUSA.selector;
2020
staticFunctionSelectors_[selectorIndex++] = this.setCoupon.selector;
2121
staticFunctionSelectors_[selectorIndex++] = this.updateMaturityDate.selector;
2222
staticFunctionSelectors_[selectorIndex++] = this.redeemAtMaturityByPartition.selector;
23+
staticFunctionSelectors_[selectorIndex++] = this.fullRedeemAtMaturity.selector;
2324
}
2425

2526
function getStaticInterfaceIds() external pure override returns (bytes4[] memory staticInterfaceIds_) {

packages/ats/contracts/scripts/domain/atsRegistry.data.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*
1111
* Import from '@scripts/domain' instead of this file directly.
1212
*
13-
* Generated: 2025-11-17T14:14:52.392Z
13+
* Generated: 2025-11-18T13:26:55.400Z
1414
* Facets: 49
1515
* Infrastructure: 2
1616
*
@@ -200,6 +200,7 @@ export const FACET_REGISTRY: Record<string, FacetDefinition> = {
200200
signature: "_initialize_bondUSA(IBondRead.BondDetailsData,RegulationData,AdditionalSecurityData)",
201201
selector: "0x653458ea",
202202
},
203+
{ name: "fullRedeemAtMaturity", signature: "fullRedeemAtMaturity(address)", selector: "0xd0db5fb2" },
203204
{
204205
name: "redeemAtMaturityByPartition",
205206
signature: "redeemAtMaturityByPartition(address,bytes32,uint256)",

packages/ats/contracts/test/contracts/unit/layer_1/bond/bond.test.ts

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ClearingTransferFacet,
2020
BondUSAReadFacet,
2121
TimeTravelFacet as TimeTravel,
22+
IERC3643,
2223
} from "@contract-types";
2324
import {
2425
DEFAULT_PARTITION,
@@ -82,6 +83,7 @@ describe("Bond Tests", () => {
8283
let protectedPartitionsFacet: ProtectedPartitions;
8384
let freezeFacet: FreezeFacet;
8485
let clearingTransferFacet: ClearingTransferFacet;
86+
let erc3643Facet: IERC3643;
8587

8688
async function deploySecurityFixture(isMultiPartition = false) {
8789
const base = await deployBondTokenFixture({
@@ -134,6 +136,10 @@ describe("Bond Tests", () => {
134136
role: ATS_ROLES._PROTECTED_PARTITIONS_ROLE,
135137
members: [signer_A.address],
136138
},
139+
{
140+
role: ATS_ROLES._AGENT_ROLE,
141+
members: [signer_A.address],
142+
},
137143
]);
138144

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

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

@@ -197,6 +204,11 @@ describe("Bond Tests", () => {
197204
await expect(
198205
bondFacet.redeemAtMaturityByPartition(ADDRESS_ZERO, DEFAULT_PARTITION, amount),
199206
).to.be.revertedWithCustomError(bondFacet, "ZeroAddressNotAllowed");
207+
208+
await expect(bondFacet.fullRedeemAtMaturity(ADDRESS_ZERO)).to.be.revertedWithCustomError(
209+
bondFacet,
210+
"ZeroAddressNotAllowed",
211+
);
200212
});
201213

202214
it("GIVEN single partition mode WHEN redeeming from a non-default partition THEN transaction fails with PartitionNotAllowedInSinglePartitionMode", async () => {
@@ -211,27 +223,34 @@ describe("Bond Tests", () => {
211223
await expect(
212224
bondFacet.connect(signer_A).redeemAtMaturityByPartition(signer_B.address, DEFAULT_PARTITION, amount),
213225
).to.be.revertedWithCustomError(bondFacet, "AccountIsBlocked");
226+
227+
await expect(bondFacet.connect(signer_A).fullRedeemAtMaturity(signer_B.address)).to.be.revertedWithCustomError(
228+
bondFacet,
229+
"AccountIsBlocked",
230+
);
214231
});
215232

216233
it("GIVEN the caller lacks the Maturity Redeemer role WHEN redeeming at maturity THEN transaction fails with AccountHasNoRole", async () => {
217234
await expect(
218235
bondFacet.connect(signer_B).redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
219236
).to.be.revertedWithCustomError(bondFacet, "AccountHasNoRole");
237+
238+
await expect(bondFacet.connect(signer_B).fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
239+
bondFacet,
240+
"AccountHasNoRole",
241+
);
220242
});
221243
it("GIVEN clearing is activated WHEN redeeming at maturity THEN transaction fails with ClearingIsActivated", async () => {
222244
await clearingActionsFacet.activateClearing();
223245

224246
await expect(
225247
bondFacet.redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
226248
).to.be.revertedWithCustomError(bondFacet, "ClearingIsActivated");
227-
});
228-
229-
it("GIVEN partitions are protected AND caller lacks required role WHEN redeeming at maturity THEN transaction fails with PartitionsAreProtectedAndNoRole", async () => {
230-
await protectedPartitionsFacet.protectPartitions();
231249

232-
await expect(
233-
bondFacet.redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
234-
).to.be.revertedWithCustomError(bondFacet, "PartitionsAreProtectedAndNoRole");
250+
await expect(bondFacet.fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
251+
bondFacet,
252+
"ClearingIsActivated",
253+
);
235254
});
236255

237256
it("GIVEN the token is paused WHEN redeeming at maturity THEN transaction fails with TokenIsPaused", async () => {
@@ -247,19 +266,48 @@ describe("Bond Tests", () => {
247266
await expect(
248267
bondFacet.connect(signer_C).redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
249268
).to.be.revertedWithCustomError(bondFacet, "TokenIsPaused");
269+
270+
await expect(bondFacet.connect(signer_C).fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
271+
bondFacet,
272+
"TokenIsPaused",
273+
);
250274
});
251275

252276
it("GIVEN the token holder lacks valid KYC status WHEN redeeming at maturity THEN transaction fails with InvalidKycStatus", async () => {
253277
await expect(
254278
bondFacet.redeemAtMaturityByPartition(signer_C.address, DEFAULT_PARTITION, amount),
255279
).to.be.revertedWithCustomError(bondFacet, "InvalidKycStatus");
280+
281+
await expect(bondFacet.fullRedeemAtMaturity(signer_C.address)).to.be.revertedWithCustomError(
282+
bondFacet,
283+
"InvalidKycStatus",
284+
);
256285
});
257286

258287
it("GIVEN the current date is before maturity WHEN redeeming at maturity THEN transaction fails with BondMaturityDateWrong", async () => {
259288
await expect(
260289
bondFacet.redeemAtMaturityByPartition(signer_A.address, DEFAULT_PARTITION, amount),
261290
).to.be.revertedWithCustomError(bondFacet, "BondMaturityDateWrong");
291+
292+
await expect(bondFacet.fullRedeemAtMaturity(signer_A.address)).to.be.revertedWithCustomError(
293+
bondFacet,
294+
"BondMaturityDateWrong",
295+
);
262296
});
297+
298+
it("GIVEN a recovered wallet WHEN redeeming at maturity THEN transaction fails with WalletRecovered", async () => {
299+
await erc3643Facet.recoveryAddress(signer_A.address, signer_B.address, ADDRESS_ZERO);
300+
301+
await expect(
302+
bondFacet.redeemAtMaturityByPartition(signer_A.address, DEFAULT_PARTITION, amount),
303+
).to.be.revertedWithCustomError(bondFacet, "WalletRecovered");
304+
305+
await expect(bondFacet.fullRedeemAtMaturity(signer_A.address)).to.be.revertedWithCustomError(
306+
bondFacet,
307+
"WalletRecovered",
308+
);
309+
});
310+
263311
it("GIVEN all conditions are met WHEN redeeming at maturity THEN transaction succeeds and emits RedeemedByPartition", async () => {
264312
await accessControlFacet.connect(signer_A).grantRole(ATS_ROLES._ISSUER_ROLE, signer_C.address);
265313

@@ -276,6 +324,23 @@ describe("Bond Tests", () => {
276324
.to.emit(bondFacet, "RedeemedByPartition")
277325
.withArgs(DEFAULT_PARTITION, signer_A.address, signer_A.address, amount, "0x", "0x");
278326
});
327+
328+
it("GIVEN all conditions are met WHEN redeeming all at maturity THEN transaction succeeds and emits RedeemedByPartition", async () => {
329+
await accessControlFacet.connect(signer_A).grantRole(ATS_ROLES._ISSUER_ROLE, signer_C.address);
330+
331+
await erc1410Facet.connect(signer_C).issueByPartition({
332+
partition: DEFAULT_PARTITION,
333+
tokenHolder: signer_A.address,
334+
value: amount,
335+
data: "0x",
336+
});
337+
338+
await timeTravelFacet.changeSystemTimestamp(maturityDate + 1);
339+
340+
await expect(bondFacet.fullRedeemAtMaturity(signer_A.address))
341+
.to.emit(bondFacet, "RedeemedByPartition")
342+
.withArgs(DEFAULT_PARTITION, signer_A.address, signer_A.address, amount, "0x", "0x");
343+
});
279344
});
280345

281346
describe("Coupons", () => {
@@ -772,5 +837,30 @@ describe("Bond Tests", () => {
772837
.to.emit(bondFacet, "RedeemedByPartition")
773838
.withArgs(_PARTITION_ID, signer_A.address, signer_A.address, amount, "0x", "0x");
774839
});
840+
841+
it("GIVEN a new diamond contract with multi-partition WHEN redeemAtMaturityByPartition is called THEN transaction success", async () => {
842+
await deploySecurityFixture(true);
843+
await accessControlFacet.connect(signer_A).grantRole(ATS_ROLES._ISSUER_ROLE, signer_C.address);
844+
await erc1410Facet.connect(signer_C).issueByPartition({
845+
partition: _PARTITION_ID,
846+
tokenHolder: signer_A.address,
847+
value: amount,
848+
data: "0x",
849+
});
850+
await erc1410Facet.connect(signer_C).issueByPartition({
851+
partition: DEFAULT_PARTITION,
852+
tokenHolder: signer_A.address,
853+
value: amount,
854+
data: "0x",
855+
});
856+
857+
await timeTravelFacet.changeSystemTimestamp(maturityDate + 1);
858+
859+
await expect(bondFacet.fullRedeemAtMaturity(signer_A.address))
860+
.to.emit(bondFacet, "RedeemedByPartition")
861+
.withArgs(_PARTITION_ID, signer_A.address, signer_A.address, amount, "0x", "0x")
862+
.to.emit(bondFacet, "RedeemedByPartition")
863+
.withArgs(DEFAULT_PARTITION, signer_A.address, signer_A.address, amount, "0x", "0x");
864+
});
775865
});
776866
});

packages/ats/sdk/__tests__/fixtures/bond/BondFixture.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import { BigNumber } from 'ethers';
4040
import { Coupon } from '@domain/context/bond/Coupon';
4141
import { RedeemAtMaturityByPartitionCommand } from '@command/bond/redeemAtMaturityByPartition/RedeemAtMaturityByPartitionCommand';
4242
import RedeemAtMaturityByPartitionRequest from '@port/in/request/bond/RedeemAtMaturityByPartitionRequest';
43+
import { FullRedeemAtMaturityCommand } from '@command/bond/fullRedeemAtMaturity/FullRedeemAtMaturityCommand';
44+
import FullRedeemAtMaturityRequest from '@port/in/request/bond/FullRedeemAtMaturityRequest';
4345
import { GetCouponHoldersQuery } from '@query/bond/coupons/getCouponHolders/GetCouponHoldersQuery';
4446
import { GetTotalCouponHoldersQuery } from '@query/bond/coupons/getTotalCouponHolders/GetTotalCouponHoldersQuery';
4547
import GetCouponHoldersRequest from '@port/in/request/bond/GetCouponHoldersRequest';
@@ -195,6 +197,12 @@ export const RedeemAtMaturityByPartitionCommandFixture =
195197
command.partitionId.as(() => PartitionIdFixture.create().value);
196198
});
197199

200+
export const FullRedeemAtMaturityCommandFixture =
201+
createFixture<FullRedeemAtMaturityCommand>((command) => {
202+
command.securityId.as(() => HederaIdPropsFixture.create().value);
203+
command.sourceId.as(() => HederaIdPropsFixture.create().value);
204+
});
205+
198206
export const BondDetailsFixture = createFixture<BondDetails>((props) => {
199207
props.currency.faker((faker) => faker.finance.currencyCode());
200208
props.nominalValue.faker((faker) =>
@@ -444,6 +452,12 @@ export const RedeemAtMaturityByPartitionRequestFixture =
444452
request.partitionId.as(() => PartitionIdFixture.create().value);
445453
});
446454

455+
export const FullRedeemAtMaturityRequestFixture =
456+
createFixture<FullRedeemAtMaturityRequest>((request) => {
457+
request.securityId.as(() => HederaIdPropsFixture.create().value);
458+
request.sourceId.as(() => HederaIdPropsFixture.create().value);
459+
});
460+
447461
export const CreateTrexSuiteBondRequestFixture =
448462
createFixture<CreateTrexSuiteBondRequest>((request) => {
449463
request.salt.faker((faker) => faker.string.alphanumeric({ length: 32 }));

0 commit comments

Comments
 (0)