Skip to content
Open
2 changes: 2 additions & 0 deletions confidential/builder/clawback_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64)

package builder

import (
Expand Down
13 changes: 9 additions & 4 deletions confidential/builder/convert_back.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import (

// BuildConvertBackParams holds minimal inputs for BuildConvertBack.
// Sequence, IssuerPubKey, AuditorPubKey, BalanceVersion, CurrentBalanceCt,
// and CurrentBalance are auto-resolved from the ledger. Balance is decrypted using HolderPrivKey.
// and CurrentBalance are auto-resolved from the ledger. Balance is decrypted using HolderPrivKey
// within BalanceRange's inclusive bounds.
type BuildConvertBackParams struct {
Account string
IssuanceID string
Amount uint64
HolderPrivKey string // 64 hex chars, also used to decrypt balance from ledger
HolderPubKey string // 66 hex chars (compressed)
HolderPrivKey string // 64 hex chars, also used to decrypt balance from ledger
HolderPubKey string // 66 hex chars (compressed)
BalanceRange elgamal.AmountRange // Inclusive balance decryption bounds
}

// ConvertBackParams holds inputs for PrepareConvertBack.
Expand Down Expand Up @@ -70,6 +72,9 @@ func BuildConvertBack(q LedgerQuerier, p BuildConvertBackParams) (*transaction.C
if err := validateConvertBackBase(p); err != nil {
return nil, err
}
if err := p.BalanceRange.Validate(); err != nil {
return nil, err
}

seq, err := getSequence(q, p.Account)
if err != nil {
Expand All @@ -90,7 +95,7 @@ func BuildConvertBack(q LedgerQuerier, p BuildConvertBackParams) (*transaction.C
return nil, fmt.Errorf("%w: holder pubkey does not match ledger", ErrCryptoFailed)
}

currentBalance, err := elgamal.Decrypt(balanceCt, p.HolderPrivKey)
currentBalance, err := elgamal.Decrypt(balanceCt, p.HolderPrivKey, p.BalanceRange)
if err != nil {
return nil, fmt.Errorf("%w: failed to decrypt balance: %w", ErrCryptoFailed, err)
}
Expand Down
72 changes: 43 additions & 29 deletions confidential/builder/convert_back_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64)

package builder

import (
Expand All @@ -6,8 +8,6 @@ import (

"github.com/Peersyst/xrpl-go/confidential/elgamal"
"github.com/Peersyst/xrpl-go/confidential/proof"
xrplhash "github.com/Peersyst/xrpl-go/xrpl/hash"
ledgerentries "github.com/Peersyst/xrpl-go/xrpl/ledger-entry-types"
"github.com/Peersyst/xrpl-go/xrpl/transaction"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -172,41 +172,55 @@ func TestPrepareConvertBack_FailValidation(t *testing.T) {
}
}

func TestBuildConvertBack_Pass(t *testing.T) {
holderKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
issuerKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)

func TestBuildConvertBack_BalanceRange(t *testing.T) {
const currentBalance uint64 = 1000
const withdrawAmount uint64 = 100

bf, err := elgamal.GenerateBlindingFactor()
require.NoError(t, err)
balanceCt, err := elgamal.Encrypt(currentBalance, holderKP.PubKeyHex, bf)
require.NoError(t, err)

issuanceIndex, err := xrplhash.MPTokenIssuance(testIssuanceID)
require.NoError(t, err)
mptokenIndex, err := xrplhash.MPToken(testIssuanceID, testAccount)
require.NoError(t, err)
tests := []struct {
name string
balanceRange elgamal.AmountRange
wantErr bool
}{
{name: "pass - balance in range", balanceRange: elgamal.AmountRange{Low: currentBalance, High: currentBalance}},
{name: "fail - balance outside range", balanceRange: elgamal.AmountRange{Low: 0, High: currentBalance - 1}, wantErr: true},
}

q := &mockQuerier{
accountSeq: 3,
entries: map[string]ledgerentries.FlatLedgerObject{
issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, ""),
mptokenIndex: buildMPTokenEntry(holderKP.PubKeyHex, balanceCt, 1, ""),
},
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
holderKP, q := newBalanceLedgerFixture(t, 3, 1, currentBalance)
result, err := BuildConvertBack(q, BuildConvertBackParams{
Account: testAccount,
IssuanceID: testIssuanceID,
Amount: 100,
HolderPrivKey: holderKP.PrivKeyHex,
HolderPubKey: holderKP.PubKeyHex,
BalanceRange: tt.balanceRange,
})
if tt.wantErr {
require.ErrorIs(t, err, ErrCryptoFailed)
require.ErrorIs(t, err, elgamal.ErrDecryptFailed)
return
}
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, uint32(3), result.Sequence)
})
}
}

func TestBuildConvertBack_InvalidRangeBeforeLedgerQueries(t *testing.T) {
holderKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)

result, err := BuildConvertBack(q, BuildConvertBackParams{
q := &mockQuerier{accountErr: ErrLedgerQuery}
_, err = BuildConvertBack(q, BuildConvertBackParams{
Account: testAccount,
IssuanceID: testIssuanceID,
Amount: withdrawAmount,
Amount: 1,
HolderPrivKey: holderKP.PrivKeyHex,
HolderPubKey: holderKP.PubKeyHex,
BalanceRange: elgamal.AmountRange{Low: 2, High: 1},
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, uint32(3), result.Sequence)
require.ErrorIs(t, err, elgamal.ErrInvalidAmountRange)
require.NotErrorIs(t, err, ErrLedgerQuery)
require.Zero(t, q.queryCalls, "invalid ranges must fail before ledger queries")
}
2 changes: 2 additions & 0 deletions confidential/builder/convert_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64)

package builder

import (
Expand Down
35 changes: 35 additions & 0 deletions confidential/builder/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package builder

import (
"testing"

"github.com/Peersyst/xrpl-go/confidential/elgamal"
xrplhash "github.com/Peersyst/xrpl-go/xrpl/hash"
ledgerentries "github.com/Peersyst/xrpl-go/xrpl/ledger-entry-types"
"github.com/Peersyst/xrpl-go/xrpl/queries/account"
"github.com/Peersyst/xrpl-go/xrpl/queries/ledger"
"github.com/stretchr/testify/require"
)

const (
Expand All @@ -17,9 +22,12 @@ type mockQuerier struct {
accountSeq uint32
accountErr error // when set, GetAccountInfo returns this error
entries map[string]ledgerentries.FlatLedgerObject
// queryCalls counts ledger requests so tests can assert failures occur before unnecessary ledger access.
queryCalls int
}

func (m *mockQuerier) GetAccountInfo(_ *account.InfoRequest) (*account.InfoResponse, error) {
m.queryCalls++
if m.accountErr != nil {
return nil, m.accountErr
}
Expand All @@ -29,6 +37,7 @@ func (m *mockQuerier) GetAccountInfo(_ *account.InfoRequest) (*account.InfoRespo
}

func (m *mockQuerier) GetLedgerEntry(req *ledger.EntryRequest) (*ledger.EntryResponse, error) {
m.queryCalls++
node, ok := m.entries[req.Index]
if !ok {
return nil, ErrMPTokenNotFound
Expand Down Expand Up @@ -64,3 +73,29 @@ func buildMPTokenEntry(holderKey, balanceCt string, balanceVersion float64, issu
}
return entry
}

func newBalanceLedgerFixture(t *testing.T, sequence uint32, balanceVersion float64, balance uint64) (elgamal.Keypair, *mockQuerier) {
t.Helper()

ownerKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
issuerKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
blindingFactor, err := elgamal.GenerateBlindingFactor()
require.NoError(t, err)
balanceCt, err := elgamal.Encrypt(balance, ownerKP.PubKeyHex, blindingFactor)
require.NoError(t, err)

issuanceIndex, err := xrplhash.MPTokenIssuance(testIssuanceID)
require.NoError(t, err)
mptokenIndex, err := xrplhash.MPToken(testIssuanceID, testAccount)
require.NoError(t, err)

return ownerKP, &mockQuerier{
accountSeq: sequence,
entries: map[string]ledgerentries.FlatLedgerObject{
issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, ""),
mptokenIndex: buildMPTokenEntry(ownerKP.PubKeyHex, balanceCt, balanceVersion, ""),
},
}
}
15 changes: 10 additions & 5 deletions confidential/builder/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ import (

// BuildSendParams holds minimal inputs for BuildSend.
// Sequence, ReceiverPubKey, IssuerPubKey, AuditorPubKey, BalanceVersion, CurrentBalanceCt,
// and CurrentBalance are auto-resolved from the ledger. Balance is decrypted using SenderPrivKey.
// and CurrentBalance are auto-resolved from the ledger. Balance is decrypted using SenderPrivKey
// within BalanceRange's inclusive bounds.
type BuildSendParams struct {
Account string
Destination string
IssuanceID string
Amount uint64
SenderPrivKey string // 64 hex chars, also used to decrypt balance from ledger
SenderPubKey string // 66 hex chars (compressed)
CredentialIDs []string // Optional
SenderPrivKey string // 64 hex chars, also used to decrypt balance from ledger
SenderPubKey string // 66 hex chars (compressed)
BalanceRange elgamal.AmountRange // Inclusive balance decryption bounds
CredentialIDs []string // Optional
}

// SendParams holds inputs for PrepareSend.
Expand Down Expand Up @@ -82,6 +84,9 @@ func BuildSend(q LedgerQuerier, p BuildSendParams) (*transaction.ConfidentialMPT
if err := validateSendBase(p); err != nil {
return nil, err
}
if err := p.BalanceRange.Validate(); err != nil {
return nil, err
}

seq, err := getSequence(q, p.Account)
if err != nil {
Expand All @@ -103,7 +108,7 @@ func BuildSend(q LedgerQuerier, p BuildSendParams) (*transaction.ConfidentialMPT
return nil, fmt.Errorf("%w: sender pubkey does not match ledger", ErrCryptoFailed)
}

currentBalance, err := elgamal.Decrypt(senderBalanceCt, p.SenderPrivKey)
currentBalance, err := elgamal.Decrypt(senderBalanceCt, p.SenderPrivKey, p.BalanceRange)
if err != nil {
return nil, fmt.Errorf("%w: failed to decrypt balance: %w", ErrCryptoFailed, err)
}
Expand Down
67 changes: 43 additions & 24 deletions confidential/builder/send_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64)

package builder

import (
Expand Down Expand Up @@ -270,36 +272,15 @@ func TestPrepareSend_FailValidation(t *testing.T) {
}

func TestBuildSend_Pass(t *testing.T) {
senderKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
receiverKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
issuerKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)

const currentBalance uint64 = 1000
const sendAmount uint64 = 300

bf, err := elgamal.GenerateBlindingFactor()
require.NoError(t, err)
senderBalanceCt, err := elgamal.Encrypt(currentBalance, senderKP.PubKeyHex, bf)
require.NoError(t, err)

issuanceIndex, err := xrplhash.MPTokenIssuance(testIssuanceID)
require.NoError(t, err)
senderMPTIndex, err := xrplhash.MPToken(testIssuanceID, testAccount)
senderKP, q := newBalanceLedgerFixture(t, 8, 2, currentBalance)
receiverKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
receiverMPTIndex, err := xrplhash.MPToken(testIssuanceID, testDestination)
require.NoError(t, err)

q := &mockQuerier{
accountSeq: 8,
entries: map[string]ledgerentries.FlatLedgerObject{
issuanceIndex: buildIssuanceEntry(issuerKP.PubKeyHex, ""),
senderMPTIndex: buildMPTokenEntry(senderKP.PubKeyHex, senderBalanceCt, 2, ""),
receiverMPTIndex: buildMPTokenEntry(receiverKP.PubKeyHex, "", 0, ""),
},
}
q.entries[receiverMPTIndex] = buildMPTokenEntry(receiverKP.PubKeyHex, "", 0, "")

result, err := BuildSend(q, BuildSendParams{
Account: testAccount,
Expand All @@ -308,13 +289,50 @@ func TestBuildSend_Pass(t *testing.T) {
Amount: sendAmount,
SenderPrivKey: senderKP.PrivKeyHex,
SenderPubKey: senderKP.PubKeyHex,
BalanceRange: elgamal.AmountRange{Low: currentBalance, High: currentBalance},
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, uint32(8), result.Sequence)
require.NotEmpty(t, result.ZKProof)
}

func TestBuildSend_FailBalanceOutsideRange(t *testing.T) {
const currentBalance uint64 = 1000

senderKP, q := newBalanceLedgerFixture(t, 8, 2, currentBalance)
_, err := BuildSend(q, BuildSendParams{
Account: testAccount,
Destination: testDestination,
IssuanceID: testIssuanceID,
Amount: 300,
SenderPrivKey: senderKP.PrivKeyHex,
SenderPubKey: senderKP.PubKeyHex,
BalanceRange: elgamal.AmountRange{Low: 0, High: currentBalance - 1},
})
require.ErrorIs(t, err, ErrCryptoFailed)
require.ErrorIs(t, err, elgamal.ErrDecryptFailed)
}

func TestBuildSend_InvalidRangeBeforeLedgerQueries(t *testing.T) {
senderKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)

q := &mockQuerier{accountErr: ErrLedgerQuery}
_, err = BuildSend(q, BuildSendParams{
Account: testAccount,
Destination: testDestination,
IssuanceID: testIssuanceID,
Amount: 1,
SenderPrivKey: senderKP.PrivKeyHex,
SenderPubKey: senderKP.PubKeyHex,
BalanceRange: elgamal.AmountRange{Low: 2, High: 1},
})
require.ErrorIs(t, err, elgamal.ErrInvalidAmountRange)
require.NotErrorIs(t, err, ErrLedgerQuery)
require.Zero(t, q.queryCalls, "invalid ranges must fail before ledger queries")
}

func TestBuildSend_FailReceiverNotOptedIn(t *testing.T) {
senderKP, err := elgamal.GenerateKeypair()
require.NoError(t, err)
Expand Down Expand Up @@ -348,6 +366,7 @@ func TestBuildSend_FailReceiverNotOptedIn(t *testing.T) {
Amount: 100,
SenderPrivKey: senderKP.PrivKeyHex,
SenderPubKey: senderKP.PubKeyHex,
BalanceRange: elgamal.AmountRange{Low: currentBalance, High: currentBalance},
})
require.ErrorIs(t, err, ErrReceiverNotOptedIn)
}
3 changes: 1 addition & 2 deletions confidential/commitment/commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ func Create(amount uint64, bfHex string) (string, error) {
return "", fmt.Errorf("%w: %w", ErrInvalidBlindingFactor, err)
}

var bf [mptcrypto.BlindingFactorSize]byte
copy(bf[:], bfBytes)
bf := mptcrypto.BlindingFactor(bfBytes)

c, err := mptcrypto.PedersenCommitment(amount, bf)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion confidential/commitment/commitment_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build cgo
//go:build cgo && !js && !wasip1 && !tinygo && !gofuzz && (linux || darwin) && (amd64 || arm64)

package commitment_test

Expand Down
Loading