@@ -3778,6 +3778,310 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
37783778 }
37793779 }
37803780
3781+ // This test verifies that proofs are non-replayable by simulating replays
3782+ // with an outdated ledger version or an old sequence number.
3783+ // It confirms that the validator detects the resulting ContextID mismatch
3784+ // and rejects the transaction with tecBAD_PROOF.
3785+ void
3786+ testProofContextBinding (FeatureBitset features)
3787+ {
3788+ testcase (" Proof context binding (Sequence and Version)" );
3789+ using namespace test ::jtx;
3790+
3791+ Env env{*this , features};
3792+ Account const alice (" alice" );
3793+ Account const bob (" bob" );
3794+ MPTTester mptAlice (env, alice, {.holders = {bob}});
3795+
3796+ mptAlice.create (
3797+ {.ownerCount = 1 , .holderCount = 0 , .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
3798+
3799+ mptAlice.authorize ({.account = bob});
3800+ mptAlice.pay (alice, bob, 100 );
3801+
3802+ mptAlice.generateKeyPair (alice);
3803+
3804+ mptAlice.set ({.account = alice, .issuerPubKey = mptAlice.getPubKey (alice)});
3805+
3806+ mptAlice.generateKeyPair (bob);
3807+
3808+ mptAlice.convert ({
3809+ .account = bob,
3810+ .amt = 40 ,
3811+ .holderPubKey = mptAlice.getPubKey (bob),
3812+ });
3813+
3814+ mptAlice.mergeInbox ({
3815+ .account = bob,
3816+ });
3817+
3818+ uint64_t const amt = 10 ;
3819+ Buffer const blindingFactor = generateBlindingFactor ();
3820+ Buffer const pcBlindingFactor = generateBlindingFactor ();
3821+
3822+ auto const spendingBalance = mptAlice.getDecryptedBalance (bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
3823+ BEAST_EXPECT (spendingBalance.has_value () && *spendingBalance == 40 ); // because bob encrypted 40
3824+ auto const encryptedSpendingBalance = mptAlice.getEncryptedBalance (bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
3825+ BEAST_EXPECT (encryptedSpendingBalance.has_value () && !encryptedSpendingBalance->empty ());
3826+
3827+ Buffer const pedersenCommitment = mptAlice.getPedersenCommitment (*spendingBalance, pcBlindingFactor);
3828+ Buffer const issuerCiphertext = mptAlice.encryptAmount (alice, amt, blindingFactor);
3829+ Buffer const bobCiphertext = mptAlice.encryptAmount (bob, amt, blindingFactor);
3830+
3831+ auto const currentVersion = mptAlice.getMPTokenVersion (bob);
3832+
3833+ // Invalid Version Binding
3834+ // Simulates replaying a full transaction after the ledger's version
3835+ // has updated. We simulate this by attempting to use a proof built
3836+ // using an older version but with the current valid sequence.
3837+ {
3838+ uint32_t const seqA = env.seq (bob);
3839+ uint32_t const oldVersion = currentVersion - 1 ;
3840+ uint256 const badContextHash = getConvertBackContextHash (bob, seqA, mptAlice.issuanceID (), amt, oldVersion);
3841+
3842+ Buffer const proof = mptAlice.getConvertBackProof (
3843+ bob,
3844+ amt,
3845+ badContextHash,
3846+ {
3847+ .pedersenCommitment = pedersenCommitment,
3848+ .amt = *spendingBalance,
3849+ .encryptedAmt = *encryptedSpendingBalance,
3850+ .blindingFactor = pcBlindingFactor,
3851+ });
3852+
3853+ mptAlice.convertBack (
3854+ {.account = bob,
3855+ .amt = amt,
3856+ .proof = proof,
3857+ .holderEncryptedAmt = bobCiphertext,
3858+ .issuerEncryptedAmt = issuerCiphertext,
3859+ .blindingFactor = blindingFactor,
3860+ .pedersenCommitment = pedersenCommitment,
3861+ .err = tecBAD_PROOF});
3862+ }
3863+
3864+ // Invalid Sequence Binding
3865+ // Simulates submitting a new transaction (with a new, valid signature
3866+ // and sequence) but reusing a ZKP from a previous sequence number.
3867+ {
3868+ // Fetch updated sequence, as the tecBAD_PROOF above consumed one
3869+ uint32_t const seqB = env.seq (bob);
3870+ uint32_t const oldSeq = seqB - 1 ;
3871+ uint256 const badContextHash =
3872+ getConvertBackContextHash (bob, oldSeq, mptAlice.issuanceID (), amt, currentVersion);
3873+
3874+ Buffer const proof = mptAlice.getConvertBackProof (
3875+ bob,
3876+ amt,
3877+ badContextHash,
3878+ {
3879+ .pedersenCommitment = pedersenCommitment,
3880+ .amt = *spendingBalance,
3881+ .encryptedAmt = *encryptedSpendingBalance,
3882+ .blindingFactor = pcBlindingFactor,
3883+ });
3884+
3885+ mptAlice.convertBack (
3886+ {.account = bob,
3887+ .amt = amt,
3888+ .proof = proof,
3889+ .holderEncryptedAmt = bobCiphertext,
3890+ .issuerEncryptedAmt = issuerCiphertext,
3891+ .blindingFactor = blindingFactor,
3892+ .pedersenCommitment = pedersenCommitment,
3893+ .err = tecBAD_PROOF});
3894+ }
3895+
3896+ // Verify Correct Proof Passes
3897+ // Ensure the test setup was correct and functions when no replay is attempted.
3898+ {
3899+ // Fetch updated sequence once more
3900+ uint32_t const seqC = env.seq (bob);
3901+ uint256 const goodContextHash =
3902+ getConvertBackContextHash (bob, seqC, mptAlice.issuanceID (), amt, currentVersion);
3903+
3904+ Buffer const proof = mptAlice.getConvertBackProof (
3905+ bob,
3906+ amt,
3907+ goodContextHash,
3908+ {
3909+ .pedersenCommitment = pedersenCommitment,
3910+ .amt = *spendingBalance,
3911+ .encryptedAmt = *encryptedSpendingBalance,
3912+ .blindingFactor = pcBlindingFactor,
3913+ });
3914+
3915+ mptAlice.convertBack ({
3916+ .account = bob,
3917+ .amt = amt,
3918+ .proof = proof,
3919+ .holderEncryptedAmt = bobCiphertext,
3920+ .issuerEncryptedAmt = issuerCiphertext,
3921+ .blindingFactor = blindingFactor,
3922+ .pedersenCommitment = pedersenCommitment,
3923+ });
3924+ }
3925+ }
3926+
3927+ // This test simulates a valid proof π extracted from a transaction
3928+ // for amount m1 is reused in a new transaction for a different
3929+ // amount m2 with different ciphertexts. It confirms the context hash
3930+ // recomputation fails due to the ciphertext binding mismatch, resulting
3931+ // in tecBAD_PROOF.
3932+ void
3933+ testProofCiphertextBinding (FeatureBitset features)
3934+ {
3935+ testcase (" Proof ciphertext binding" );
3936+ using namespace test ::jtx;
3937+
3938+ Env env{*this , features};
3939+ Account const alice (" alice" );
3940+ Account const bob (" bob" );
3941+ MPTTester mptAlice (env, alice, {.holders = {bob}});
3942+
3943+ mptAlice.create (
3944+ {.ownerCount = 1 , .holderCount = 0 , .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
3945+
3946+ mptAlice.authorize ({.account = bob});
3947+ mptAlice.pay (alice, bob, 100 );
3948+
3949+ mptAlice.generateKeyPair (alice);
3950+ mptAlice.set ({.account = alice, .issuerPubKey = mptAlice.getPubKey (alice)});
3951+
3952+ mptAlice.generateKeyPair (bob);
3953+ mptAlice.convert ({
3954+ .account = bob,
3955+ .amt = 50 ,
3956+ .holderPubKey = mptAlice.getPubKey (bob),
3957+ });
3958+
3959+ mptAlice.mergeInbox ({
3960+ .account = bob,
3961+ });
3962+
3963+ auto const spendingBalance = mptAlice.getDecryptedBalance (bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
3964+ auto const encryptedSpendingBalance = mptAlice.getEncryptedBalance (bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
3965+ auto const version = mptAlice.getMPTokenVersion (bob);
3966+ Buffer const pcBlindingFactor = generateBlindingFactor ();
3967+ Buffer const pedersenCommitment = mptAlice.getPedersenCommitment (*spendingBalance, pcBlindingFactor);
3968+
3969+ // Generate a valid proof pi for Amount m1 = 10
3970+ uint64_t const amtA = 10 ;
3971+ uint32_t const currentSeq = env.seq (bob);
3972+ uint256 const contextHashA = getConvertBackContextHash (bob, currentSeq, mptAlice.issuanceID (), amtA, version);
3973+
3974+ Buffer const proofA = mptAlice.getConvertBackProof (
3975+ bob,
3976+ amtA,
3977+ contextHashA,
3978+ {
3979+ .pedersenCommitment = pedersenCommitment,
3980+ .amt = *spendingBalance,
3981+ .encryptedAmt = *encryptedSpendingBalance,
3982+ .blindingFactor = pcBlindingFactor,
3983+ });
3984+
3985+ // Construct Transaction B with Amount m2 = 20 and attach Proof pi
3986+ uint64_t const amtB = 20 ;
3987+ Buffer const blindingFactorB = generateBlindingFactor ();
3988+ Buffer const bobCiphertextB = mptAlice.encryptAmount (bob, amtB, blindingFactorB);
3989+ Buffer const issuerCiphertextB = mptAlice.encryptAmount (alice, amtB, blindingFactorB);
3990+
3991+ // We attempt to verify the proof pi (for amt 10) against the new ciphertexts (for amt 20).
3992+ mptAlice.convertBack (
3993+ {.account = bob,
3994+ .amt = amtB,
3995+ .proof = proofA, // Extracted/Reused proof from Transaction A
3996+ .holderEncryptedAmt = bobCiphertextB,
3997+ .issuerEncryptedAmt = issuerCiphertextB,
3998+ .blindingFactor = blindingFactorB,
3999+ .pedersenCommitment = pedersenCommitment,
4000+ .err = tecBAD_PROOF}); // Expected failure
4001+ }
4002+
4003+ // This test simulates a valid proof π and ciphertext are
4004+ // tied to version v, but are reused after an inbox merge has incremented
4005+ // the CBS version to v+1. It confirms the validator rejects the transaction
4006+ // before acceptance due to the ContextID mismatch.
4007+ void
4008+ testProofVersionMismatch (FeatureBitset features)
4009+ {
4010+ testcase (" Proof version mismatch" );
4011+ using namespace test ::jtx;
4012+
4013+ Env env{*this , features};
4014+ Account const alice (" alice" );
4015+ Account const bob (" bob" );
4016+ MPTTester mptAlice (env, alice, {.holders = {bob}});
4017+
4018+ mptAlice.create (
4019+ {.ownerCount = 1 , .holderCount = 0 , .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanPrivacy});
4020+
4021+ mptAlice.authorize ({.account = bob});
4022+ mptAlice.pay (alice, bob, 1000 );
4023+
4024+ mptAlice.generateKeyPair (alice);
4025+ mptAlice.set ({.account = alice, .issuerPubKey = mptAlice.getPubKey (alice)});
4026+
4027+ mptAlice.generateKeyPair (bob);
4028+
4029+ // Initial state: Version v
4030+ // Convert and merge to establish a spending balance and initial version
4031+ mptAlice.convert ({
4032+ .account = bob,
4033+ .amt = 100 ,
4034+ .holderPubKey = mptAlice.getPubKey (bob),
4035+ });
4036+ mptAlice.mergeInbox ({.account = bob});
4037+
4038+ auto const versionV = mptAlice.getMPTokenVersion (bob);
4039+ auto const spendingBalanceV = mptAlice.getDecryptedBalance (bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
4040+ auto const encryptedSpendingBalanceV = mptAlice.getEncryptedBalance (bob, MPTTester::HOLDER_ENCRYPTED_SPENDING);
4041+
4042+ // Parameters for the intended ConvertBack transaction
4043+ uint64_t const amt = 10 ;
4044+ Buffer const blindingFactor = generateBlindingFactor ();
4045+ Buffer const pcBlindingFactor = generateBlindingFactor ();
4046+ Buffer const pedersenCommitment = mptAlice.getPedersenCommitment (*spendingBalanceV, pcBlindingFactor);
4047+ Buffer const issuerCiphertext = mptAlice.encryptAmount (alice, amt, blindingFactor);
4048+ Buffer const bobCiphertext = mptAlice.encryptAmount (bob, amt, blindingFactor);
4049+
4050+ // State Change: Increment version to v+1
4051+ // Converting more funds and merging increments the sfConfidentialBalanceVersion
4052+ mptAlice.convert ({.account = bob, .amt = 50 });
4053+ mptAlice.mergeInbox ({.account = bob});
4054+
4055+ BEAST_EXPECT (mptAlice.getMPTokenVersion (bob) > versionV);
4056+
4057+ // Attack: Attempt to reuse proof tied to Version v at ledger Version v+1
4058+ uint32_t const currentSeq = env.seq (bob);
4059+ // Proof is explicitly generated using the outdated Version v
4060+ uint256 const oldContextHash = getConvertBackContextHash (bob, currentSeq, mptAlice.issuanceID (), amt, versionV);
4061+
4062+ Buffer const oldProof = mptAlice.getConvertBackProof (
4063+ bob,
4064+ amt,
4065+ oldContextHash,
4066+ {
4067+ .pedersenCommitment = pedersenCommitment,
4068+ .amt = *spendingBalanceV,
4069+ .encryptedAmt = *encryptedSpendingBalanceV,
4070+ .blindingFactor = pcBlindingFactor,
4071+ });
4072+
4073+ // Submit and verify failure
4074+ mptAlice.convertBack (
4075+ {.account = bob,
4076+ .amt = amt,
4077+ .proof = oldProof,
4078+ .holderEncryptedAmt = bobCiphertext,
4079+ .issuerEncryptedAmt = issuerCiphertext,
4080+ .blindingFactor = blindingFactor,
4081+ .pedersenCommitment = pedersenCommitment,
4082+ .err = tecBAD_PROOF}); // Fails because TransactionContextID differs
4083+ }
4084+
37814085 void
37824086 testWithFeats (FeatureBitset features)
37834087 {
@@ -3820,6 +4124,9 @@ class ConfidentialTransfer_test : public beast::unit_test::suite
38204124 testConvertBackBulletproof (features);
38214125
38224126 testMutatePrivacy (features);
4127+ testProofContextBinding (features);
4128+ testProofCiphertextBinding (features);
4129+ testProofVersionMismatch (features);
38234130 }
38244131
38254132public:
0 commit comments