@@ -3707,6 +3707,187 @@ class Invariants_test : public beast::unit_test::suite
37073707 precloseMpt);
37083708 }
37093709
3710+ void
3711+ testValidConfidentialMPToken ()
3712+ {
3713+ using namespace test ::jtx;
3714+ testcase << " ValidConfidentialMPToken" ;
3715+
3716+ MPTID mptID;
3717+
3718+ // Generate an MPT with privacy, issue 100 tokens to A2.
3719+ // Perform a confidential conversion to populate encrypted state.
3720+ auto const precloseConfidential = [&mptID](Account const & A1, Account const & A2, Env& env) -> bool {
3721+ MPTTester mpt (env, A1, {.holders = {A2}, .fund = false });
3722+ mpt.create ({.flags = tfMPTCanTransfer | tfMPTCanPrivacy});
3723+ mptID = mpt.issuanceID ();
3724+
3725+ mpt.authorize ({.account = A2});
3726+ mpt.pay (A1, A2, 100 );
3727+
3728+ mpt.generateKeyPair (A1);
3729+ mpt.set ({.account = A1, .issuerPubKey = mpt.getPubKey (A1)});
3730+
3731+ mpt.generateKeyPair (A2);
3732+ mpt.convert ({
3733+ .account = A2,
3734+ .amt = 100 ,
3735+ .holderPubKey = mpt.getPubKey (A2),
3736+ });
3737+ return true ;
3738+ };
3739+
3740+ // badDelete
3741+ doInvariantCheck (
3742+ {" MPToken deleted with encrypted fields while COA > 0" },
3743+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3744+ auto sleToken = ac.view ().peek (keylet::mptoken (mptID, A2.id ()));
3745+ if (!sleToken)
3746+ return false ;
3747+ // Force an erase of the object while the COA remains 100
3748+ ac.view ().erase (sleToken);
3749+ return true ;
3750+ },
3751+ XRPAmount{},
3752+ STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
3753+ {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
3754+ precloseConfidential);
3755+
3756+ // badConsistency
3757+ doInvariantCheck (
3758+ {" MPToken encrypted field existence inconsistency" },
3759+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3760+ auto sleToken = ac.view ().peek (keylet::mptoken (mptID, A2.id ()));
3761+ if (!sleToken)
3762+ return false ;
3763+ // Remove one of the required encrypted fields to create a mismatch
3764+ sleToken->makeFieldAbsent (sfIssuerEncryptedBalance);
3765+ ac.view ().update (sleToken);
3766+ return true ;
3767+ },
3768+ XRPAmount{},
3769+ STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
3770+ {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
3771+ precloseConfidential);
3772+
3773+ // requiresPrivacyFlag
3774+ auto const precloseNoPrivacy = [&mptID](Account const & A1, Account const & A2, Env& env) -> bool {
3775+ MPTTester mpt (env, A1, {.holders = {A2}, .fund = false });
3776+ // completely omitted the tfMPTCanPrivacy flag here.
3777+ mpt.create ({.flags = tfMPTCanTransfer});
3778+ mptID = mpt.issuanceID ();
3779+ mpt.authorize ({.account = A2});
3780+ mpt.pay (A1, A2, 100 );
3781+ return true ;
3782+ };
3783+
3784+ doInvariantCheck (
3785+ {" MPToken has encrypted fields but Issuance does not have lsfMPTCanPrivacy set" },
3786+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3787+ auto sleToken = ac.view ().peek (keylet::mptoken (mptID, A2.id ()));
3788+ if (!sleToken)
3789+ return false ;
3790+ // Inject fields correctly, but the Issuance was built without the privacy flag.
3791+ sleToken->setFieldVL (sfConfidentialBalanceInbox, Blob{0x00 });
3792+ sleToken->setFieldVL (sfIssuerEncryptedBalance, Blob{0x00 });
3793+ ac.view ().update (sleToken);
3794+ return true ;
3795+ },
3796+ XRPAmount{},
3797+ STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
3798+ {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
3799+ precloseNoPrivacy);
3800+
3801+ // badCOA
3802+ doInvariantCheck (
3803+ {" Confidential outstanding amount exceeds total outstanding amount" },
3804+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3805+ auto sleIssuance = ac.view ().peek (keylet::mptIssuance (mptID));
3806+ if (!sleIssuance)
3807+ return false ;
3808+ // Total outstanding is natively 100; bloat the COA over 100
3809+ sleIssuance->setFieldU64 (sfConfidentialOutstandingAmount, 200 );
3810+ ac.view ().update (sleIssuance);
3811+ return true ;
3812+ },
3813+ XRPAmount{},
3814+ STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}},
3815+ {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
3816+ precloseConfidential);
3817+
3818+ // Conservation Violation
3819+ doInvariantCheck (
3820+ {" Token conservation violation for MPT" },
3821+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3822+ auto sleToken = ac.view ().peek (keylet::mptoken (mptID, A2.id ()));
3823+ if (!sleToken)
3824+ return false ;
3825+ // Adding extra amount to standard balance; breaks conservation maths.
3826+ sleToken->setFieldU64 (sfMPTAmount, sleToken->getFieldU64 (sfMPTAmount) + 50 );
3827+ ac.view ().update (sleToken);
3828+ return true ;
3829+ },
3830+ XRPAmount{},
3831+ STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
3832+ {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
3833+ precloseConfidential);
3834+
3835+ // badVersion
3836+ doInvariantCheck (
3837+ {" MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending changed" },
3838+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3839+ auto sleToken = ac.view ().peek (keylet::mptoken (mptID, A2.id ()));
3840+ if (!sleToken)
3841+ return false ;
3842+ sleToken->setFieldVL (sfConfidentialBalanceSpending, Blob{0xBA , 0xDD });
3843+
3844+ // DO NOT update sfConfidentialBalanceVersion
3845+ ac.view ().update (sleToken);
3846+ return true ;
3847+ },
3848+ XRPAmount{},
3849+ STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
3850+ {tecINVARIANT_FAILED, tecINVARIANT_FAILED},
3851+ precloseConfidential);
3852+
3853+ // Skipping Deleted MPTs (Issuance deleted)
3854+ auto const precloseOrphan = [&mptID](Account const & A1, Account const & A2, Env& env) -> bool {
3855+ MPTTester mpt (env, A1, {.holders = {A2}, .fund = false });
3856+ mpt.create ({.flags = tfMPTCanTransfer | tfMPTCanPrivacy});
3857+ mptID = mpt.issuanceID ();
3858+ mpt.authorize ({.account = A2});
3859+
3860+ // Generate privacy keys and convert 0 amount so Bob has the encrypted fields
3861+ mpt.generateKeyPair (A1);
3862+ mpt.set ({.account = A1, .issuerPubKey = mpt.getPubKey (A1)});
3863+ mpt.generateKeyPair (A2);
3864+ mpt.convert ({
3865+ .account = A2,
3866+ .amt = 0 ,
3867+ .holderPubKey = mpt.getPubKey (A2),
3868+ });
3869+
3870+ // Immediately destroy the issuance. A2's empty, encrypted token object lives on.
3871+ mpt.destroy ();
3872+ return true ;
3873+ };
3874+
3875+ doInvariantCheck (
3876+ {},
3877+ [&mptID](Account const & A1, Account const & A2, ApplyContext& ac) {
3878+ auto sleToken = ac.view ().peek (keylet::mptoken (mptID, A2.id ()));
3879+ if (!sleToken)
3880+ return false ;
3881+ // Safely able to erase the deleted token.
3882+ ac.view ().erase (sleToken);
3883+ return true ;
3884+ },
3885+ XRPAmount{},
3886+ STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
3887+ {tesSUCCESS, tesSUCCESS},
3888+ precloseOrphan);
3889+ }
3890+
37103891public:
37113892 void
37123893 run () override
@@ -3732,6 +3913,7 @@ class Invariants_test : public beast::unit_test::suite
37323913 testValidPseudoAccounts ();
37333914 testValidLoanBroker ();
37343915 testVault ();
3916+ testValidConfidentialMPToken ();
37353917 }
37363918};
37373919
0 commit comments